From cb3fa028c301ea66103aae54bdb9394de73df59e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 25 Apr 2026 04:10:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20create=20custom=20autocomp?= =?UTF-8?q?lete=20tag=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/machines-selector.tsx | 161 +++-------- src/components/tags/autocomplete.tsx | 28 +- src/components/tags/suggestions-tag-input.tsx | 266 ++++++++++++++++++ src/components/tags/tag-input.tsx | 47 +--- 4 files changed, 330 insertions(+), 172 deletions(-) create mode 100644 src/components/tags/suggestions-tag-input.tsx diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index cc2b5966b..d4aef49d6 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -5,8 +5,10 @@ import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; -import { MultiSelectTags } from "./multi-select/multi-select-tags"; -import { TagInput, type TagInputProps } from "./tags/tag-input"; +import { + SuggestionsTagInput, + type SuggestionsTagInputProps +} from "./tags/suggestions-tag-input"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -18,19 +20,14 @@ export type MachineSelectorProps = { selectedMachines?: SelectedMachine[]; onSelectMachines: (machine: SelectedMachine[]) => void; } & Omit< - TagInputProps, - | "activeTagIndex" - | "setActiveTagIndex" - | "placeholder" - | "size" + SuggestionsTagInputProps, | "tags" | "setTags" - | "value" + | "suggestedOptions" | "searchQuery" | "onSearchQueryChange" - | "suggestedOptions" - | "enableAutocomplete" - | "autocompleteOptions" + | "activeTagIndex" + | "setActiveTagIndex" >; export function MachinesSelector({ @@ -48,7 +45,7 @@ export function MachinesSelector({ orgQueries.machineClients({ orgId, perPage: 3, query: debouncedValue }) ); - // always include the selected machines in the list of machines shown (if the user isn't searching) + // always include the selected machines in the list (if the user isn't searching) const machinesShown = useMemo(() => { const allMachines: Array = [...machines]; if (debouncedValue.trim().length === 0) { @@ -60,117 +57,45 @@ export function MachinesSelector({ } } } - return allMachines; }, [machines, selectedMachines, debouncedValue]); - // const selectedMachinesIds = new Set( - // selectedMachines.map((m) => m.clientId) - // ); - const [activeTagIndex, setActiveTagIndex] = useState(null); return ( - <> - ({ - id: mc.clientId.toString(), - text: mc.name - }))} - setTags={(newTags) => { - const tags = - typeof newTags === "function" - ? newTags( - selectedMachines.map((mc) => ({ - id: mc.clientId.toString(), - text: mc.name - })) - ) - : newTags; - onSelectMachines( - tags.map((tag) => ({ - clientId: Number(tag.id), - name: tag.text - })) - ); - }} - searchQuery={machineSearchQuery} - onSearchQueryChange={setMachineSearchQuery} - suggestedOptions={machinesShown.map((mc) => ({ - id: mc.clientId.toString(), - text: mc.name - }))} - allowDuplicates={false} - restrictTagsToAutocompleteOptions - sortTags - /> - - // ({ - // ...m, - // text: m.name, - // id: m.clientId.toString() - // }))} - // onChange={(values) => { - // onSelectMachines(values); - // }} - // options={machinesShown.map((m) => ({ - // ...m, - // id: m.clientId.toString(), - // text: m.name - // }))} - // onSearch={setMachineSearchQuery} - // searchQuery={machineSearchQuery} - // /> - - // - // - // - // {t("machineNotFound")} - // - // {machinesShown.map((m) => ( - // { - // let newMachineClients = []; - // if (selectedMachinesIds.has(m.clientId)) { - // newMachineClients = selectedMachines.filter( - // (mc) => mc.clientId !== m.clientId - // ); - // } else { - // newMachineClients = [ - // ...selectedMachines, - // m - // ]; - // } - // onSelectMachines(newMachineClients); - // }} - // > - // - // {`${m.name}`} - // - // ))} - // - // - // + ({ + id: mc.clientId.toString(), + text: mc.name + }))} + setTags={(newTags) => { + const tags = + typeof newTags === "function" + ? newTags( + selectedMachines.map((mc) => ({ + id: mc.clientId.toString(), + text: mc.name + })) + ) + : newTags; + onSelectMachines( + tags.map((tag) => ({ + clientId: Number(tag.id), + name: tag.text + })) + ); + }} + searchQuery={machineSearchQuery} + onSearchQueryChange={setMachineSearchQuery} + suggestedOptions={machinesShown.map((mc) => ({ + id: mc.clientId.toString(), + text: mc.name + }))} + allowDuplicates={false} + /> ); } diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index 609a6e009..938853a1d 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -41,9 +41,6 @@ type AutocompleteProps = { usePortal?: boolean; /** Narrows the dropdown list from the main field (cmdk search filters further). */ filterQuery?: string; - /** When true, skip internal filtering and make the CommandInput controlled — parent owns filtering. */ - disableSearch?: boolean; - onFilterQueryChange?: (value: string) => void; }; export const Autocomplete: React.FC = ({ @@ -60,9 +57,7 @@ export const Autocomplete: React.FC = ({ children, classStyleProps, usePortal, - filterQuery = "", - disableSearch = false, - onFilterQueryChange + filterQuery = "" }) => { const triggerContainerRef = useRef(null); const inputRef = useRef(null); @@ -75,13 +70,12 @@ export const Autocomplete: React.FC = ({ const [commandResetKey, setCommandResetKey] = useState(0); const visibleOptions = useMemo(() => { - if (disableSearch) return autocompleteOptions; const q = filterQuery.trim().toLowerCase(); if (!q) return autocompleteOptions; return autocompleteOptions.filter((option) => option.text.toLowerCase().includes(q) ); - }, [autocompleteOptions, filterQuery, disableSearch]); + }, [autocompleteOptions, filterQuery]); useEffect(() => { if (isPopoverOpen) { @@ -281,25 +275,15 @@ export const Autocomplete: React.FC = ({ > - {disableSearch ? ( - - ) : ( - - )} + >; + suggestedOptions: Tag[]; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + activeTagIndex: number | null; + setActiveTagIndex: React.Dispatch>; + placeholder?: string; + maxTags?: number; + onTagAdd?: (tag: string) => void; + onTagRemove?: (tag: string) => void; + allowDuplicates?: boolean; + disabled?: boolean; + usePortal?: boolean; + styleClasses?: TagInputStyleClassesProps; +} & VariantProps; + +export function SuggestionsTagInput({ + tags, + setTags, + suggestedOptions, + searchQuery, + onSearchQueryChange, + activeTagIndex, + setActiveTagIndex, + placeholder, + maxTags, + onTagAdd, + onTagRemove, + allowDuplicates = false, + disabled = false, + usePortal = false, + styleClasses = {}, + variant, + size, + shape, + borderStyle, + textCase, + interaction, + animation, + textStyle +}: SuggestionsTagInputProps) { + const t = useTranslations(); + const triggerRef = useRef(null); + const popoverContentRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [popoverWidth, setPopoverWidth] = useState(0); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent | TouchEvent) => { + if ( + isOpen && + triggerRef.current && + popoverContentRef.current && + !triggerRef.current.contains(event.target as Node) && + !popoverContentRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => + document.removeEventListener("mousedown", handleOutsideClick); + }, [isOpen]); + + const handleOpenChange = (open: boolean) => { + if (open && triggerRef.current) { + setPopoverWidth(triggerRef.current.getBoundingClientRect().width); + } + if (open) setIsOpen(true); + }; + + const toggleTag = (option: Tag) => { + const index = tags.findIndex((tag) => tag.text === option.text); + if (index >= 0) { + setTags(tags.filter((_, i) => i !== index)); + onTagRemove?.(option.text); + } else { + if ( + !allowDuplicates && + tags.some((tag) => tag.text === option.text) + ) + return; + if (!maxTags || tags.length < maxTags) { + setTags([...tags, option]); + onTagAdd?.(option.text); + } + } + }; + + const removeTag = (idToRemove: string) => { + const removed = tags.find((tag) => tag.id === idToRemove); + setTags(tags.filter((tag) => tag.id !== idToRemove)); + if (removed) onTagRemove?.(removed.text); + }; + + const onSortEnd = (oldIndex: number, newIndex: number) => { + setTags((current) => { + const next = [...current]; + const [moved] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, moved); + return next; + }); + }; + + return ( + + +
+ + + + +
+
+ + + + + {t("noResults")} + + {suggestedOptions.map((option) => { + const isChosen = tags.some( + (tag) => tag.text === option.text + ); + return ( + toggleTag(option)} + className={ + styleClasses?.autoComplete + ?.commandItem + } + > + + {option.text} + + ); + })} + + + + +
+ ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index b20787e0c..3241912c5 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -88,7 +88,6 @@ export interface TagInputProps searchQuery?: string; onSearchQueryChange?: (value: string) => void; autocompleteContent?: React.ReactNode; - suggestedOptions?: Tag[]; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -164,8 +163,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { generateTagId = uuid, searchQuery, onSearchQueryChange, - autocompleteContent, - suggestedOptions + autocompleteContent } = props; const [inputValue, setInputValue] = React.useState(""); @@ -196,7 +194,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { } const handleInputChange = (e: React.ChangeEvent) => { - if (suggestedOptions !== undefined) return; const newValue = e.target.value; if (addOnPaste && newValue.includes(delimiter)) { const splitValues = newValue @@ -440,14 +437,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { onClearAll?.(); }; - const mainInputValue = - suggestedOptions !== undefined ? "" : effectiveQuery; - - const useAutocompleteComponent = - enableAutocomplete || suggestedOptions !== undefined; - const resolvedAutocompleteOptions = suggestedOptions ?? autocompleteOptions; - const disableAutocompleteSearch = suggestedOptions !== undefined; - const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -500,7 +489,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { disabled={disabled} /> ) : ( - !useAutocompleteComponent && !autocompleteContent && ( + !enableAutocomplete && !autocompleteContent && (
) ))} - {!useAutocompleteComponent && autocompleteContent && ( + {!enableAutocomplete && autocompleteContent && (
)} - {useAutocompleteComponent ? ( + {enableAutocomplete ? (
= maxTags ? placeholderWhenFull : placeholder} // ref={inputRef} - // value={mainInputValue} + // value={effectiveQuery} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // onChangeCapture={handleInputChange} // onKeyDown={handleKeyDown} @@ -697,7 +680,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -758,7 +741,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={mainInputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -781,7 +764,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -837,7 +820,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={mainInputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -859,7 +842,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -902,7 +885,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -962,7 +945,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus}