♻️ create custom autocomplete tag input

This commit is contained in:
Fred KISSIE
2026-04-25 04:10:54 +02:00
parent c746e1bc8d
commit cb3fa028c3
4 changed files with 330 additions and 172 deletions

View File

@@ -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<SelectedMachine> = [...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<number | null>(null);
return (
<>
<TagInput
{...props}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t("accessClientSelect")}
size="sm"
tags={selectedMachines.map((mc) => ({
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
/>
</>
// <MultiSelectTags
// emptyPlaceholder={t("machineNotFound")}
// searchPlaceholder={t("machineSearch")}
// value={selectedMachines.map((m) => ({
// ...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}
// />
// <Command shouldFilter={false}>
// <CommandInput
// placeholder={t("machineSearch")}
// value={machineSearchQuery}
// onValueChange={setMachineSearchQuery}
// />
// <CommandList>
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
// <CommandGroup>
// {machinesShown.map((m) => (
// <CommandItem
// value={`${m.name}:${m.clientId}`}
// key={m.clientId}
// onSelect={() => {
// let newMachineClients = [];
// if (selectedMachinesIds.has(m.clientId)) {
// newMachineClients = selectedMachines.filter(
// (mc) => mc.clientId !== m.clientId
// );
// } else {
// newMachineClients = [
// ...selectedMachines,
// m
// ];
// }
// onSelectMachines(newMachineClients);
// }}
// >
// <CheckIcon
// className={cn(
// "mr-2 h-4 w-4",
// selectedMachinesIds.has(m.clientId)
// ? "opacity-100"
// : "opacity-0"
// )}
// />
// {`${m.name}`}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
<SuggestionsTagInput
{...props}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t("accessClientSelect")}
tags={selectedMachines.map((mc) => ({
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}
/>
);
}

View File

@@ -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<AutocompleteProps> = ({
@@ -60,9 +57,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
children,
classStyleProps,
usePortal,
filterQuery = "",
disableSearch = false,
onFilterQueryChange
filterQuery = ""
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -75,13 +70,12 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
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<AutocompleteProps> = ({
>
<Command
key={commandResetKey}
shouldFilter={!disableSearch}
className={cn(
"rounded-lg border-0 shadow-none",
classStyleProps?.command
)}
>
{disableSearch ? (
<CommandInput
placeholder={t("searchPlaceholder")}
className="h-9"
value={filterQuery}
onValueChange={onFilterQueryChange}
/>
) : (
<CommandInput
placeholder={t("searchPlaceholder")}
className="h-9"
/>
)}
<CommandInput
placeholder={t("searchPlaceholder")}
className="h-9"
/>
<CommandList
className={cn(
"max-h-[300px]",

View File

@@ -0,0 +1,266 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { type VariantProps } from "class-variance-authority";
import { Check } from "lucide-react";
import { useTranslations } from "next-intl";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import { tagVariants } from "./tag";
import { TagList } from "./tag-list";
import type { Tag, TagInputStyleClassesProps } from "./tag-input";
export type SuggestionsTagInputProps = {
tags: Tag[];
setTags: React.Dispatch<React.SetStateAction<Tag[]>>;
suggestedOptions: Tag[];
searchQuery: string;
onSearchQueryChange: (value: string) => void;
activeTagIndex: number | null;
setActiveTagIndex: React.Dispatch<React.SetStateAction<number | null>>;
placeholder?: string;
maxTags?: number;
onTagAdd?: (tag: string) => void;
onTagRemove?: (tag: string) => void;
allowDuplicates?: boolean;
disabled?: boolean;
usePortal?: boolean;
styleClasses?: TagInputStyleClassesProps;
} & VariantProps<typeof tagVariants>;
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<HTMLDivElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(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 (
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={usePortal}>
<PopoverAnchor asChild>
<div
ref={triggerRef}
className={cn(
"flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input text-sm bg-transparent pr-1",
styleClasses?.inlineTagsContainer
)}
>
<TagList
tags={tags}
variant={variant}
size={size}
shape={shape}
borderStyle={borderStyle}
textCase={textCase}
interaction={interaction}
animation={animation}
textStyle={textStyle}
onRemoveTag={removeTag}
onSortEnd={onSortEnd}
inlineTags
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
tagListClasses: styleClasses?.tagList,
tagClasses: styleClasses?.tag
}}
disabled={disabled}
/>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
role="combobox"
type="button"
disabled={
disabled ||
(maxTags !== undefined &&
tags.length >= maxTags)
}
className={cn(
"hover:bg-transparent ml-auto",
styleClasses?.autoComplete?.popoverTrigger
)}
onClick={() => setIsOpen(!isOpen)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 transition-transform ${isOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
</Button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent
ref={popoverContentRef}
side="bottom"
align="start"
forceMount
className={cn("p-0", styleClasses?.autoComplete?.popoverContent)}
style={{
width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`,
zIndex: 9999
}}
>
<Command
shouldFilter={false}
className={cn(
"rounded-lg border-0 shadow-none",
styleClasses?.autoComplete?.command
)}
>
<CommandInput
placeholder={placeholder ?? t("searchPlaceholder")}
className="h-9"
value={searchQuery}
onValueChange={onSearchQueryChange}
/>
<CommandList
className={cn(
"max-h-[300px]",
styleClasses?.autoComplete?.commandList
)}
>
<CommandEmpty>{t("noResults")}</CommandEmpty>
<CommandGroup
className={styleClasses?.autoComplete?.commandGroup}
>
{suggestedOptions.map((option) => {
const isChosen = tags.some(
(tag) => tag.text === option.text
);
return (
<CommandItem
key={option.id}
value={`${option.text} ${option.id}`}
onSelect={() => toggleTag(option)}
className={
styleClasses?.autoComplete
?.commandItem
}
>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
isChosen
? "opacity-100"
: "opacity-0"
)}
/>
{option.text}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
@@ -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<HTMLInputElement>) => {
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 && (
<div className="w-full">
<div
className={cn(
@@ -543,7 +532,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={mainInputValue}
value={effectiveQuery}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
@@ -572,7 +561,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
</div>
)
))}
{!useAutocompleteComponent && autocompleteContent && (
{!enableAutocomplete && autocompleteContent && (
<div className="w-full">
<div
className={cn(
@@ -614,7 +603,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull
: placeholder
}
value={mainInputValue}
value={effectiveQuery}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
@@ -634,22 +623,16 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{autocompleteContent}
</div>
)}
{useAutocompleteComponent ? (
{enableAutocomplete ? (
<div className="w-full">
<Autocomplete
tags={tags}
setTags={setTags}
setInputValue={updateQuery}
autocompleteOptions={
(resolvedAutocompleteOptions || []) as Tag[]
(autocompleteOptions || []) as Tag[]
}
filterQuery={effectiveQuery}
disableSearch={disableAutocompleteSearch}
onFilterQueryChange={
disableAutocompleteSearch
? onSearchQueryChange
: undefined
}
setTagCount={setTagCount}
maxTags={maxTags}
onTagAdd={onTagAdd}
@@ -675,7 +658,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
// <CommandInput
// placeholder={maxTags !== undefined && tags.length >= 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) {
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= 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) {
{/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= 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}