pangolin/src/components/tags/tag-popover.tsx
2025-02-26 21:24:35 -05:00

207 lines
7.5 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
type TagPopoverProps = {
children: React.ReactNode;
tags: TagType[];
customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
activeTagIndex?: number | null;
setActiveTagIndex?: (index: number | null) => void;
classStyleProps: {
popoverClasses: TagInputStyleClassesProps["tagPopover"];
tagListClasses: TagInputStyleClassesProps["tagList"];
tagClasses: TagInputStyleClassesProps["tag"];
};
disabled?: boolean;
usePortal?: boolean;
} & TagListProps;
export const TagPopover: React.FC<TagPopoverProps> = ({
children,
tags,
customTagRenderer,
activeTagIndex,
setActiveTagIndex,
classStyleProps,
disabled,
usePortal,
...tagProps
}) => {
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);
setSideOffset(
triggerContainerRef.current.offsetWidth -
triggerRef?.current?.offsetWidth
);
}
};
handleResize(); // Call on mount and layout changes
window.addEventListener("resize", handleResize); // Adjust on window resize
return () => window.removeEventListener("resize", handleResize);
}, [triggerContainerRef, triggerRef]);
// 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) {
setPopoverWidth(triggerContainerRef.current.offsetWidth);
}
if (open) {
inputRef.current?.focus();
setIsPopoverOpen(open);
}
},
[inputFocused]
);
const handleInputFocus = (
event:
| React.FocusEvent<HTMLInputElement>
| React.FocusEvent<HTMLTextAreaElement>
) => {
// 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);
};
return (
<Popover
open={isPopoverOpen}
onOpenChange={handleOpenChange}
modal={usePortal}
>
<div
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,
onBlur: handleInputBlur,
ref: inputRef
})}
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="ghost"
size="icon"
role="combobox"
className={cn(
`hover:bg-transparent`,
classStyleProps?.popoverClasses?.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}
className={cn(
`w-full space-y-3`,
classStyleProps?.popoverClasses?.popoverContent
)}
style={{
marginLeft: `-${sideOffset}px`,
width: `${popoverWidth}px`
}}
>
<div className="space-y-1">
<h4 className="text-sm font-medium leading-none">
Entered Tags
</h4>
<p className="text-sm text-muted-foregrounsd text-left">
These are the tags you&apos;ve entered.
</p>
</div>
<TagList
tags={tags}
customTagRenderer={customTagRenderer}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
classStyleProps={{
tagListClasses: classStyleProps?.tagListClasses,
tagClasses: classStyleProps?.tagClasses
}}
{...tagProps}
disabled={disabled}
/>
</PopoverContent>
</Popover>
);
};