pangolin/src/components/tags/autocomplete.tsx
2025-04-13 14:46:13 -04:00

353 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command';
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
type AutocompleteProps = {
tags: TagType[];
setTags: React.Dispatch<React.SetStateAction<TagType[]>>;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
setTagCount: React.Dispatch<React.SetStateAction<number>>;
autocompleteOptions: TagType[];
maxTags?: number;
onTagAdd?: (tag: string) => void;
onTagRemove?: (tag: string) => void;
allowDuplicates: boolean;
children: React.ReactNode;
inlineTags?: boolean;
classStyleProps: TagInputStyleClassesProps["autoComplete"];
usePortal?: boolean;
};
export const Autocomplete: React.FC<AutocompleteProps> = ({
tags,
setTags,
setInputValue,
setTagCount,
autocompleteOptions,
maxTags,
onTagAdd,
onTagRemove,
allowDuplicates,
inlineTags,
children,
classStyleProps,
usePortal
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
// Dynamically calculate the top position for the popover content
useEffect(() => {
if (!triggerContainerRef.current || !triggerRef.current) return;
setPopoverContentTop(
triggerContainerRef.current?.getBoundingClientRect().bottom -
triggerRef.current?.getBoundingClientRect().bottom
);
}, [tags]);
// Close the popover when clicking outside of it
useEffect(() => {
const handleOutsideClick = (
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
) => {
if (
isPopoverOpen &&
triggerContainerRef.current &&
popoverContentRef.current &&
!triggerContainerRef.current.contains(event.target as Node) &&
!popoverContentRef.current.contains(event.target as Node)
) {
setIsPopoverOpen(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [isPopoverOpen]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && triggerContainerRef.current) {
const { width } =
triggerContainerRef.current.getBoundingClientRect();
setPopoverWidth(width);
}
if (open) {
inputRef.current?.focus();
setIsPopoverOpen(open);
}
},
[inputFocused]
);
const handleInputFocus = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
if (triggerContainerRef.current) {
const { width } =
triggerContainerRef.current.getBoundingClientRect();
setPopoverWidth(width);
setIsPopoverOpen(true);
}
// Only set inputFocused to true if the popover is already open.
// This will prevent the popover from opening due to an input focus if it was initially closed.
if (isPopoverOpen) {
setInputFocused(true);
}
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
if (userOnFocus) userOnFocus(event);
};
const handleInputBlur = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
setInputFocused(false);
// Allow the popover to close if no other interactions keep it open
if (!isPopoverOpen) {
setIsPopoverOpen(false);
}
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
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) => {
// Check if the tag already exists in the array
const index = tags.findIndex((tag) => tag.text === option.text);
if (index >= 0) {
// Tag exists, remove it
const newTags = tags.filter((_, i) => i !== index);
setTags(newTags);
setTagCount((prevCount) => prevCount - 1);
if (onTagRemove) {
onTagRemove(option.text);
}
} else {
// Tag doesn't exist, add it if allowed
if (
!allowDuplicates &&
tags.some((tag) => tag.text === option.text)
) {
// If duplicates aren't allowed and a tag with the same text exists, do nothing
return;
}
// Add the tag if it doesn't exceed max tags, if applicable
if (!maxTags || tags.length < maxTags) {
setTags([...tags, option]);
setTagCount((prevCount) => prevCount + 1);
setInputValue("");
if (onTagAdd) {
onTagAdd(option.text);
}
}
}
setSelectedIndex(-1);
};
const childrenWithProps = React.cloneElement(
children as React.ReactElement<any>,
{
onKeyDown: handleKeyDown,
onFocus: handleInputFocus,
onBlur: handleInputBlur,
ref: inputRef
}
);
return (
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
classStyleProps?.command
)}
>
<Popover
open={isPopoverOpen}
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3"
ref={triggerContainerRef}
>
{childrenWithProps}
<PopoverTrigger asChild ref={triggerRef}>
<Button
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
classStyleProps?.popoverTrigger
)}
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>
</Button>
</PopoverTrigger>
</div>
<PopoverContent
ref={popoverContentRef}
side="bottom"
align="start"
forceMount
className={cn(
`p-0 relative`,
classStyleProps?.popoverContent
)}
style={{
top: `${popooverContentTop}px`,
marginLeft: `calc(-${popoverWidth}px + 36px)`,
width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`,
zIndex: 9999
}}
>
<div
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
classStyleProps?.commandList
)}
style={{
minHeight: "68px"
}}
key={autocompleteOptions.length}
>
{autocompleteOptions.length > 0 ? (
<div
key={autocompleteOptions.length}
role="group"
className={cn(
"overflow-y-auto overflow-hidden p-1 text-foreground",
classStyleProps?.commandGroup
)}
style={{
minHeight: "68px"
}}
>
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
Suggestions
</span>
<div role="separator" className="py-0.5" />
{autocompleteOptions.map((option, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={option.id}
role="option"
aria-selected={isSelected}
className={cn(
"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">
{option.text}
{tags.some(
(tag) =>
tag.text === option.text
) && (
<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>
);
})}
</div>
) : (
<div className="py-6 text-center text-sm">
No results found.
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};