mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-11 23:04:59 +00:00
⏪ revert changes modifying existing tag input
This commit is contained in:
@@ -1,266 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,6 @@ export interface TagInputProps
|
|||||||
onInputChange?: (value: string) => void;
|
onInputChange?: (value: string) => void;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
onSearchQueryChange?: (value: string) => void;
|
onSearchQueryChange?: (value: string) => void;
|
||||||
autocompleteContent?: React.ReactNode;
|
|
||||||
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
|
||||||
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
onFocus?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
@@ -162,8 +161,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
addOnPaste = false,
|
addOnPaste = false,
|
||||||
generateTagId = uuid,
|
generateTagId = uuid,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange
|
||||||
autocompleteContent
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [inputValue, setInputValue] = React.useState("");
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
@@ -489,7 +487,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!enableAutocomplete && !autocompleteContent && (
|
!enableAutocomplete && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -561,68 +559,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
{!enableAutocomplete && autocompleteContent && (
|
|
||||||
<div className="w-full">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
|
|
||||||
styleClasses?.inlineTagsContainer
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TagList
|
|
||||||
tags={truncatedTags}
|
|
||||||
customTagRenderer={customTagRenderer}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
shape={shape}
|
|
||||||
borderStyle={borderStyle}
|
|
||||||
textCase={textCase}
|
|
||||||
interaction={interaction}
|
|
||||||
animation={animation}
|
|
||||||
textStyle={textStyle}
|
|
||||||
onTagClick={onTagClick}
|
|
||||||
draggable={draggable}
|
|
||||||
onSortEnd={onSortEnd}
|
|
||||||
onRemoveTag={removeTag}
|
|
||||||
direction={direction}
|
|
||||||
inlineTags={inlineTags}
|
|
||||||
activeTagIndex={activeTagIndex}
|
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
|
||||||
classStyleProps={{
|
|
||||||
tagListClasses: styleClasses?.tagList,
|
|
||||||
tagClasses: styleClasses?.tag
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
id={id}
|
|
||||||
type="text"
|
|
||||||
placeholder={
|
|
||||||
maxTags !== undefined && tags.length >= maxTags
|
|
||||||
? placeholderWhenFull
|
|
||||||
: placeholder
|
|
||||||
}
|
|
||||||
value={effectiveQuery}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={handleInputFocus}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
{...inputProps}
|
|
||||||
className={cn(
|
|
||||||
"border-0 px-2 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
|
|
||||||
styleClasses?.input
|
|
||||||
)}
|
|
||||||
autoComplete="off"
|
|
||||||
disabled={
|
|
||||||
disabled ||
|
|
||||||
(maxTags !== undefined && tags.length >= maxTags)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{autocompleteContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{enableAutocomplete ? (
|
{enableAutocomplete ? (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
|
|||||||
Reference in New Issue
Block a user