support search in tags input

This commit is contained in:
miloschwartz
2026-03-28 18:29:40 -07:00
parent 50ee28b1f7
commit d1b2105c80
3 changed files with 177 additions and 215 deletions

View File

@@ -1,10 +1,23 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "../ui/command";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "../ui/popover";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Check } from "lucide-react";
type AutocompleteProps = { type AutocompleteProps = {
tags: TagType[]; tags: TagType[];
@@ -20,6 +33,8 @@ type AutocompleteProps = {
inlineTags?: boolean; inlineTags?: boolean;
classStyleProps: TagInputStyleClassesProps["autoComplete"]; classStyleProps: TagInputStyleClassesProps["autoComplete"];
usePortal?: boolean; usePortal?: boolean;
/** Narrows the dropdown list from the main field (cmdk search filters further). */
filterQuery?: string;
}; };
export const Autocomplete: React.FC<AutocompleteProps> = ({ export const Autocomplete: React.FC<AutocompleteProps> = ({
@@ -35,10 +50,10 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
inlineTags, inlineTags,
children, children,
classStyleProps, classStyleProps,
usePortal usePortal,
filterQuery = ""
}) => { }) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null); const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null); const popoverContentRef = useRef<HTMLDivElement | null>(null);
const t = useTranslations(); const t = useTranslations();
@@ -46,17 +61,21 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
const [popoverWidth, setPopoverWidth] = useState<number>(0); const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const [popooverContentTop, setPopoverContentTop] = useState<number>(0); const [commandResetKey, setCommandResetKey] = useState(0);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
// Dynamically calculate the top position for the popover content const visibleOptions = useMemo(() => {
useEffect(() => { const q = filterQuery.trim().toLowerCase();
if (!triggerContainerRef.current || !triggerRef.current) return; if (!q) return autocompleteOptions;
setPopoverContentTop( return autocompleteOptions.filter((option) =>
triggerContainerRef.current?.getBoundingClientRect().bottom - option.text.toLowerCase().includes(q)
triggerRef.current?.getBoundingClientRect().bottom
); );
}, [tags]); }, [autocompleteOptions, filterQuery]);
useEffect(() => {
if (isPopoverOpen) {
setCommandResetKey((k) => k + 1);
}
}, [isPopoverOpen]);
// Close the popover when clicking outside of it // Close the popover when clicking outside of it
useEffect(() => { useEffect(() => {
@@ -135,36 +154,6 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
if (userOnBlur) userOnBlur(event); if (userOnBlur) userOnBlur(event);
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!isPopoverOpen) return;
switch (event.key) {
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex <= 0
? autocompleteOptions.length - 1
: prevIndex - 1
);
break;
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prevIndex) =>
prevIndex === autocompleteOptions.length - 1
? 0
: prevIndex + 1
);
break;
case "Enter":
event.preventDefault();
if (selectedIndex !== -1) {
toggleTag(autocompleteOptions[selectedIndex]);
setSelectedIndex(-1);
}
break;
}
};
const toggleTag = (option: TagType) => { const toggleTag = (option: TagType) => {
// Check if the tag already exists in the array // Check if the tag already exists in the array
const index = tags.findIndex((tag) => tag.text === option.text); const index = tags.findIndex((tag) => tag.text === option.text);
@@ -197,18 +186,25 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
} }
} }
} }
setSelectedIndex(-1);
}; };
const childrenWithProps = React.cloneElement( const child = children as React.ReactElement<
children as React.ReactElement<any>, React.InputHTMLAttributes<HTMLInputElement> & {
{ ref?: React.Ref<HTMLInputElement>;
onKeyDown: handleKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
} }
); >;
const userOnKeyDown = child.props.onKeyDown;
const childrenWithProps = React.cloneElement(child, {
onKeyDown: userOnKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
} as Partial<
React.InputHTMLAttributes<HTMLInputElement> & {
ref?: React.Ref<HTMLInputElement>;
}
>);
return ( return (
<div <div
@@ -222,132 +218,105 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
modal={usePortal} modal={usePortal}
> >
<div <PopoverAnchor asChild>
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3" <div
ref={triggerContainerRef} className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
> ref={triggerContainerRef}
{childrenWithProps} >
<PopoverTrigger asChild ref={triggerRef}> {childrenWithProps}
<Button <PopoverTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
role="combobox" size="icon"
className={cn( role="combobox"
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`, className={cn(
classStyleProps?.popoverTrigger `hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
)} classStyleProps?.popoverTrigger
onClick={() => { )}
setIsPopoverOpen(!isPopoverOpen); onClick={() => {
}} setIsPopoverOpen(!isPopoverOpen);
> }}
<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 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
> >
<path d="m6 9 6 6 6-6"></path> <svg
</svg> xmlns="http://www.w3.org/2000/svg"
</Button> width="24"
</PopoverTrigger> height="24"
</div> 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 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent <PopoverContent
ref={popoverContentRef} ref={popoverContentRef}
side="bottom" side="bottom"
align="start" align="start"
forceMount forceMount
className={cn( className={cn(
`p-0 relative`, "p-0",
classStyleProps?.popoverContent classStyleProps?.popoverContent
)} )}
style={{ style={{
top: `${popooverContentTop}px`,
marginLeft: `calc(-${popoverWidth}px + 36px)`,
width: `${popoverWidth}px`, width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`, minWidth: `${popoverWidth}px`,
zIndex: 9999 zIndex: 9999
}} }}
> >
<div <Command
key={commandResetKey}
className={cn( className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden", "rounded-lg border-0 shadow-none",
classStyleProps?.commandList classStyleProps?.command
)} )}
style={{
minHeight: "68px"
}}
key={autocompleteOptions.length}
> >
{autocompleteOptions.length > 0 ? ( <CommandInput
<div placeholder={t("searchPlaceholder")}
key={autocompleteOptions.length} className="h-9"
role="group" />
className={cn( <CommandList
"overflow-y-auto overflow-hidden p-1 text-foreground", className={cn(
classStyleProps?.commandGroup "max-h-[300px]",
)} classStyleProps?.commandList
style={{ )}
minHeight: "68px" >
}} <CommandEmpty>{t("noResults")}</CommandEmpty>
<CommandGroup
className={classStyleProps?.commandGroup}
> >
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2"> {visibleOptions.map((option) => {
Suggestions const isChosen = tags.some(
</span> (tag) => tag.text === option.text
<div role="separator" className="py-0.5" /> );
{autocompleteOptions.map((option, index) => {
const isSelected = index === selectedIndex;
return ( return (
<div <CommandItem
key={option.id} key={option.id}
role="option" value={`${option.text} ${option.id}`}
aria-selected={isSelected} onSelect={() => toggleTag(option)}
className={cn( className={classStyleProps?.commandItem}
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
isSelected &&
"bg-accent text-accent-foreground",
classStyleProps?.commandItem
)}
data-value={option.text}
onClick={() => toggleTag(option)}
> >
<div className="w-full flex items-center gap-2"> <Check
{option.text} className={cn(
{tags.some( "mr-2 h-4 w-4 shrink-0",
(tag) => isChosen
tag.text === option.text ? "opacity-100"
) && ( : "opacity-0"
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-check"
>
<path d="M20 6 9 17l-5-5"></path>
</svg>
)} )}
</div> />
</div> {option.text}
</CommandItem>
); );
})} })}
</div> </CommandGroup>
) : ( </CommandList>
<div className="py-6 text-center text-sm"> </Command>
{t("noResults")}
</div>
)}
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React from "react";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { type VariantProps } from "class-variance-authority"; import { type VariantProps } from "class-variance-authority";
@@ -434,14 +434,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// const filteredAutocompleteOptions = autocompleteFilter // const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions; // : autocompleteOptions;
const filteredAutocompleteOptions = useMemo(() => {
return (autocompleteOptions || []).filter((option) =>
option.text
.toLowerCase()
.includes(inputValue ? inputValue.toLowerCase() : "")
);
}, [inputValue, autocompleteOptions]);
const displayedTags = sortTags ? [...tags].sort() : tags; const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate const truncatedTags = truncate
@@ -571,9 +563,9 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}
setInputValue={setInputValue} setInputValue={setInputValue}
autocompleteOptions={ autocompleteOptions={(autocompleteOptions ||
filteredAutocompleteOptions as Tag[] []) as Tag[]}
} filterQuery={inputValue}
setTagCount={setTagCount} setTagCount={setTagCount}
maxTags={maxTags} maxTags={maxTags}
onTagAdd={onTagAdd} onTagAdd={onTagAdd}

View File

@@ -1,5 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger
} from "../ui/popover";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list"; import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
@@ -33,33 +38,27 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
...tagProps ...tagProps
}) => { }) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null); const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null); const popoverContentRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0); const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
const t = useTranslations(); const t = useTranslations();
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) { if (triggerContainerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth); setPopoverWidth(triggerContainerRef.current.offsetWidth);
setSideOffset(
triggerContainerRef.current.offsetWidth -
triggerRef?.current?.offsetWidth
);
} }
}; };
handleResize(); // Call on mount and layout changes handleResize();
window.addEventListener("resize", handleResize); // Adjust on window resize window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, [triggerContainerRef, triggerRef]); }, []);
// Close the popover when clicking outside of it // Close the popover when clicking outside of it
useEffect(() => { useEffect(() => {
@@ -135,52 +134,54 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
modal={usePortal} modal={usePortal}
> >
<div <PopoverAnchor asChild>
className="relative flex items-center rounded-md border border-input bg-transparent pr-3" <div
ref={triggerContainerRef} className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
> ref={triggerContainerRef}
{React.cloneElement(children as React.ReactElement<any>, { >
onFocus: handleInputFocus, {React.cloneElement(children as React.ReactElement<any>, {
onBlur: handleInputBlur, onFocus: handleInputFocus,
ref: inputRef onBlur: handleInputBlur,
})} ref: inputRef
<PopoverTrigger asChild> })}
<Button <PopoverTrigger asChild>
ref={triggerRef} <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
role="combobox" role="combobox"
className={cn( className={cn(
`hover:bg-transparent`, `hover:bg-transparent`,
classStyleProps?.popoverClasses?.popoverTrigger classStyleProps?.popoverClasses?.popoverTrigger
)} )}
onClick={() => setIsPopoverOpen(!isPopoverOpen)} onClick={() => setIsPopoverOpen(!isPopoverOpen)}
>
<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 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
> >
<path d="m6 9 6 6 6-6"></path> <svg
</svg> xmlns="http://www.w3.org/2000/svg"
</Button> width="24"
</PopoverTrigger> height="24"
</div> 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 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</Button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent <PopoverContent
ref={popoverContentRef} ref={popoverContentRef}
align="start"
side="bottom"
className={cn( className={cn(
`w-full space-y-3`, `w-full space-y-3`,
classStyleProps?.popoverClasses?.popoverContent classStyleProps?.popoverClasses?.popoverContent
)} )}
style={{ style={{
marginLeft: `-${sideOffset}px`,
width: `${popoverWidth}px` width: `${popoverWidth}px`
}} }}
> >