+
)}
/>
-
+
) : (
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx
index fa329ba9..35eb29a3 100644
--- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx
+++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx
@@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
+
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
-
)}
/>
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx
index 704d3f44..4a850b33 100644
--- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx
+++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx
@@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
+
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
-
)}
/>
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
index 0e3dc7bc..07bd0e53 100644
--- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
+++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
@@ -15,7 +15,7 @@ import {
} from "@server/routers/resource";
import { Button } from "@app/components/ui/button";
import { set, z } from "zod";
-import { Tag } from "emblor";
+// import { Tag } from "emblor";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -27,7 +27,7 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
-import { TagInput } from "emblor";
+// import { TagInput } from "emblor";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch";
@@ -49,6 +49,7 @@ import {
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
+import { Tag, TagInput } from "@app/components/tags/tag-input";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -429,7 +430,6 @@ export default function ResourceAuthenticationPage() {
Roles
- {/* @ts-ignore */}
+
- These roles will be able
- to access this resource.
Admins can always access
this resource.
-
)}
/>
@@ -494,7 +492,6 @@ export default function ResourceAuthenticationPage() {
Users
- {/* @ts-ignore */}
-
- Users added here will be
- able to access this
- resource. A user will
- always have access to a
- resource if they have a
- role that has access to
- it.
-
)}
@@ -732,7 +720,9 @@ export default function ResourceAuthenticationPage() {
/>
- Press enter to add an email after typing it in the input field.
+ Press enter to add an
+ email after typing it in
+ the input field.
)}
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
index 8dd10944..d8d1e8a0 100644
--- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
+++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
@@ -483,8 +483,7 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
- Setup SSL to secure your connections with
- LetsEncrypt certificates
+ Setup SSL to secure your connections with Let's Encrypt certificates
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
index 0790605c..0eef41d6 100644
--- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
@@ -291,11 +291,11 @@ export default function GeneralForm() {
+
This is the display name of the
resource.
-
)}
/>
@@ -348,7 +348,7 @@ export default function GeneralForm() {
)}
-
+
(
-
-
-
+
+
+
+
+
+
)}
/>
-
+
+
This is the port that will
be used to access the
resource.
-
)}
/>
@@ -583,7 +585,7 @@ export default function GeneralForm() {
@@ -626,10 +628,6 @@ export default function GeneralForm() {
-
- Select the new site to transfer
- this resource to.
-
)}
@@ -645,7 +643,6 @@ export default function GeneralForm() {
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
- variant="destructive"
>
Transfer Resource
diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
index aa9cb74c..bd5778ef 100644
--- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
+++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
@@ -305,7 +305,7 @@ export default function CreateShareLinkForm({
-
+
No
@@ -374,7 +374,6 @@ export default function CreateShareLinkForm({
@@ -437,7 +436,6 @@ export default function CreateShareLinkForm({
diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx
index 98fcc2a6..0a4cca14 100644
--- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx
+++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx
@@ -272,17 +272,13 @@ PersistentKeepalive = 5`
Name
-
+
-
- This is the name that will be displayed for
- this site.
-
+
+ This is the the display name for the
+ site.
+
)}
/>
@@ -319,10 +315,10 @@ PersistentKeepalive = 5`
+
This is how you will expose connections.
-
)}
/>
@@ -354,7 +350,7 @@ PersistentKeepalive = 5`
) : form.watch("method") === "wireguard" &&
isLoading ? (
Loading WireGuard configuration...
- ) : form.watch("method") === "newt" ? (
+ ) : form.watch("method") === "newt" && siteDefaults ? (
<>
- Expand for Docker Deployment
- Details
+ Expand for Docker
+ Deployment Details
diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
index b1c24405..66a7ddd1 100644
--- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx
@@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
const GeneralFormSchema = z.object({
- name: z.string()
+ name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer;
@@ -114,11 +114,11 @@ export default function GeneralPage() {
+
This is the display name of the
- site
+ site.
-
)}
/>
diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx
index a87762fe..5bc525bf 100644
--- a/src/app/auth/reset-password/ResetPasswordForm.tsx
+++ b/src/app/auth/reset-password/ResetPasswordForm.tsx
@@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
-import { formatAxiosError } from "@app/lib/api";;
+import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
@@ -223,16 +223,13 @@ export default function ResetPasswordForm({
Email
-
+
+
We'll send a password reset
code to this email address.
-
)}
/>
@@ -255,7 +252,6 @@ export default function ResetPasswordForm({
Email
@@ -276,12 +272,15 @@ export default function ResetPasswordForm({
+
+ Check your email for the
+ reset code.
+
)}
/>
@@ -298,7 +297,6 @@ export default function ResetPasswordForm({
@@ -317,7 +315,6 @@ export default function ResetPasswordForm({
@@ -349,7 +346,9 @@ export default function ResetPasswordForm({
@@ -518,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@@ -577,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx
index f839284e..9a4129b4 100644
--- a/src/app/auth/signup/SignupForm.tsx
+++ b/src/app/auth/signup/SignupForm.tsx
@@ -145,7 +145,7 @@ export default function SignupForm({
Email
-
+
@@ -160,7 +160,6 @@ export default function SignupForm({
@@ -177,7 +176,6 @@ export default function SignupForm({
diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx
index e0dcbffb..67ee0b02 100644
--- a/src/app/auth/verify-email/VerifyEmailForm.tsx
+++ b/src/app/auth/verify-email/VerifyEmailForm.tsx
@@ -145,7 +145,6 @@ export default function VerifyEmailForm({
Email
@@ -196,12 +195,12 @@ export default function VerifyEmailForm({
+
We sent a verification code to your
email address. Please enter the code
to verify your email address.
-
)}
/>
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index 318ceed9..7966d587 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -200,7 +200,6 @@ export default function StepperForm() {
{
@@ -242,7 +241,6 @@ export default function StepperForm() {
diff --git a/src/components/Disable2FaForm.tsx b/src/components/Disable2FaForm.tsx
index 3e87bce4..28da2b31 100644
--- a/src/components/Disable2FaForm.tsx
+++ b/src/components/Disable2FaForm.tsx
@@ -135,7 +135,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx
index d9167999..7d9764d7 100644
--- a/src/components/Enable2FaForm.tsx
+++ b/src/components/Enable2FaForm.tsx
@@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
@@ -246,7 +245,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx
index 2190e2f5..3be11528 100644
--- a/src/components/LoginForm.tsx
+++ b/src/components/LoginForm.tsx
@@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Email
@@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx
new file mode 100644
index 00000000..95c57bec
--- /dev/null
+++ b/src/components/tags/autocomplete.tsx
@@ -0,0 +1,353 @@
+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>;
+ setInputValue: React.Dispatch>;
+ setTagCount: React.Dispatch>;
+ 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 = ({
+ tags,
+ setTags,
+ setInputValue,
+ setTagCount,
+ autocompleteOptions,
+ maxTags,
+ onTagAdd,
+ onTagRemove,
+ allowDuplicates,
+ inlineTags,
+ children,
+ classStyleProps,
+ usePortal
+}) => {
+ const triggerContainerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const inputRef = useRef(null);
+ const popoverContentRef = useRef(null);
+
+ const [popoverWidth, setPopoverWidth] = useState(0);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [inputFocused, setInputFocused] = useState(false);
+ const [popooverContentTop, setPopoverContentTop] = useState(0);
+ const [selectedIndex, setSelectedIndex] = useState(-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
+ | React.FocusEvent
+ ) => {
+ 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).props.onFocus;
+ if (userOnFocus) userOnFocus(event);
+ };
+
+ const handleInputBlur = (
+ event:
+ | React.FocusEvent
+ | React.FocusEvent
+ ) => {
+ setInputFocused(false);
+
+ // Allow the popover to close if no other interactions keep it open
+ if (!isPopoverOpen) {
+ setIsPopoverOpen(false);
+ }
+
+ const userOnBlur = (children as React.ReactElement).props.onBlur;
+ if (userOnBlur) userOnBlur(event);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ 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,
+ {
+ onKeyDown: handleKeyDown,
+ onFocus: handleInputFocus,
+ onBlur: handleInputBlur,
+ ref: inputRef
+ }
+ );
+
+ return (
+
+
+
+ {childrenWithProps}
+
+ {
+ setIsPopoverOpen(!isPopoverOpen);
+ }}
+ >
+
+
+
+
+
+
+
+
+ {autocompleteOptions.length > 0 ? (
+
+
+ Suggestions
+
+
+ {autocompleteOptions.map((option, index) => {
+ const isSelected = index === selectedIndex;
+ return (
+
toggleTag(option)}
+ >
+
+ {option.text}
+ {tags.some(
+ (tag) =>
+ tag.text === option.text
+ ) && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ No results found.
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx
new file mode 100644
index 00000000..ef1a27fb
--- /dev/null
+++ b/src/components/tags/tag-input.tsx
@@ -0,0 +1,949 @@
+"use client";
+
+import React, { useMemo } from "react";
+import { Input } from "../ui/input";
+import { Button } from "../ui/button";
+import { type VariantProps } from "class-variance-authority";
+// import { CommandInput } from '../ui/command';
+import { TagPopover } from "./tag-popover";
+import { TagList } from "./tag-list";
+import { tagVariants } from "./tag";
+import { Autocomplete } from "./autocomplete";
+import { cn } from "@app/lib/cn";
+
+export enum Delimiter {
+ Comma = ",",
+ Enter = "Enter"
+}
+
+type OmittedInputProps = Omit<
+ React.InputHTMLAttributes,
+ "size" | "value"
+>;
+
+export type Tag = {
+ id: string;
+ text: string;
+};
+
+export interface TagInputStyleClassesProps {
+ inlineTagsContainer?: string;
+ tagPopover?: {
+ popoverTrigger?: string;
+ popoverContent?: string;
+ };
+ tagList?: {
+ container?: string;
+ sortableList?: string;
+ };
+ autoComplete?: {
+ command?: string;
+ popoverTrigger?: string;
+ popoverContent?: string;
+ commandList?: string;
+ commandGroup?: string;
+ commandItem?: string;
+ };
+ tag?: {
+ body?: string;
+ closeButton?: string;
+ };
+ input?: string;
+ clearAllButton?: string;
+}
+
+export interface TagInputProps
+ extends OmittedInputProps,
+ VariantProps {
+ placeholder?: string;
+ tags: Tag[];
+ setTags: React.Dispatch>;
+ enableAutocomplete?: boolean;
+ autocompleteOptions?: Tag[];
+ maxTags?: number;
+ minTags?: number;
+ readOnly?: boolean;
+ disabled?: boolean;
+ onTagAdd?: (tag: string) => void;
+ onTagRemove?: (tag: string) => void;
+ allowDuplicates?: boolean;
+ validateTag?: (tag: string) => boolean;
+ delimiter?: Delimiter;
+ showCount?: boolean;
+ placeholderWhenFull?: string;
+ sortTags?: boolean;
+ delimiterList?: string[];
+ truncate?: number;
+ minLength?: number;
+ maxLength?: number;
+ usePopoverForTags?: boolean;
+ value?:
+ | string
+ | number
+ | readonly string[]
+ | { id: string; text: string }[];
+ autocompleteFilter?: (option: string) => boolean;
+ direction?: "row" | "column";
+ onInputChange?: (value: string) => void;
+ customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onTagClick?: (tag: Tag) => void;
+ draggable?: boolean;
+ inputFieldPosition?: "bottom" | "top";
+ clearAll?: boolean;
+ onClearAll?: () => void;
+ inputProps?: React.InputHTMLAttributes;
+ restrictTagsToAutocompleteOptions?: boolean;
+ inlineTags?: boolean;
+ activeTagIndex: number | null;
+ setActiveTagIndex: React.Dispatch>;
+ styleClasses?: TagInputStyleClassesProps;
+ usePortal?: boolean;
+ addOnPaste?: boolean;
+ addTagsOnBlur?: boolean;
+ generateTagId?: () => string;
+}
+
+const TagInput = React.forwardRef(
+ (props, ref) => {
+ const {
+ id,
+ placeholder,
+ tags,
+ setTags,
+ variant,
+ size,
+ shape,
+ enableAutocomplete,
+ autocompleteOptions,
+ maxTags,
+ delimiter = Delimiter.Comma,
+ onTagAdd,
+ onTagRemove,
+ allowDuplicates,
+ showCount,
+ validateTag,
+ placeholderWhenFull = "Max tags reached",
+ sortTags,
+ delimiterList,
+ truncate,
+ autocompleteFilter,
+ borderStyle,
+ textCase,
+ interaction,
+ animation,
+ textStyle,
+ minLength,
+ maxLength,
+ direction = "row",
+ onInputChange,
+ customTagRenderer,
+ onFocus,
+ onBlur,
+ onTagClick,
+ draggable = false,
+ inputFieldPosition = "bottom",
+ clearAll = false,
+ onClearAll,
+ usePopoverForTags = false,
+ inputProps = {},
+ restrictTagsToAutocompleteOptions,
+ inlineTags = true,
+ addTagsOnBlur = false,
+ activeTagIndex,
+ setActiveTagIndex,
+ styleClasses = {},
+ disabled = false,
+ usePortal = false,
+ addOnPaste = false,
+ generateTagId = uuid
+ } = props;
+
+ const [inputValue, setInputValue] = React.useState("");
+ const [tagCount, setTagCount] = React.useState(
+ Math.max(0, tags.length)
+ );
+ const inputRef = React.useRef(null);
+
+ if (
+ (maxTags !== undefined && maxTags < 0) ||
+ (props.minTags !== undefined && props.minTags < 0)
+ ) {
+ console.warn("maxTags and minTags cannot be less than 0");
+ // error
+ return null;
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ if (addOnPaste && newValue.includes(delimiter)) {
+ const splitValues = newValue
+ .split(delimiter)
+ .map((v) => v.trim())
+ .filter((v) => v);
+ splitValues.forEach((value) => {
+ if (!value) return; // Skip empty strings from split
+
+ const newTagText = value.trim();
+
+ // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
+ if (
+ restrictTagsToAutocompleteOptions &&
+ !autocompleteOptions?.some(
+ (option) => option.text === newTagText
+ )
+ ) {
+ console.warn(
+ "Tag not allowed as per autocomplete options"
+ );
+ return;
+ }
+
+ if (validateTag && !validateTag(newTagText)) {
+ console.warn("Invalid tag as per validateTag");
+ return;
+ }
+
+ if (minLength && newTagText.length < minLength) {
+ console.warn(`Tag "${newTagText}" is too short`);
+ return;
+ }
+
+ if (maxLength && newTagText.length > maxLength) {
+ console.warn(`Tag "${newTagText}" is too long`);
+ return;
+ }
+
+ const newTagId = generateTagId();
+
+ // Add tag if duplicates are allowed or tag does not already exist
+ if (
+ allowDuplicates ||
+ !tags.some((tag) => tag.text === newTagText)
+ ) {
+ if (maxTags === undefined || tags.length < maxTags) {
+ // Check for maxTags limit
+ const newTag = { id: newTagId, text: newTagText };
+ setTags((prevTags) => [...prevTags, newTag]);
+ onTagAdd?.(newTagText);
+ } else {
+ console.warn(
+ "Reached the maximum number of tags allowed"
+ );
+ }
+ } else {
+ console.warn(`Duplicate tag "${newTagText}" not added`);
+ }
+ });
+ setInputValue("");
+ } else {
+ setInputValue(newValue);
+ }
+ onInputChange?.(newValue);
+ };
+
+ const handleInputFocus = (
+ event: React.FocusEvent
+ ) => {
+ setActiveTagIndex(null); // Reset active tag index when the input field gains focus
+ onFocus?.(event);
+ };
+
+ const handleInputBlur = (event: React.FocusEvent) => {
+ if (addTagsOnBlur && inputValue.trim()) {
+ const newTagText = inputValue.trim();
+
+ if (validateTag && !validateTag(newTagText)) {
+ return;
+ }
+
+ if (minLength && newTagText.length < minLength) {
+ console.warn("Tag is too short");
+ return;
+ }
+
+ if (maxLength && newTagText.length > maxLength) {
+ console.warn("Tag is too long");
+ return;
+ }
+
+ if (
+ (allowDuplicates ||
+ !tags.some((tag) => tag.text === newTagText)) &&
+ (maxTags === undefined || tags.length < maxTags)
+ ) {
+ const newTagId = generateTagId();
+ setTags([...tags, { id: newTagId, text: newTagText }]);
+ onTagAdd?.(newTagText);
+ setTagCount((prevTagCount) => prevTagCount + 1);
+ setInputValue("");
+ }
+ }
+
+ onBlur?.(event);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (
+ delimiterList
+ ? delimiterList.includes(e.key)
+ : e.key === delimiter || e.key === Delimiter.Enter
+ ) {
+ e.preventDefault();
+ const newTagText = inputValue.trim();
+
+ // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
+ if (
+ restrictTagsToAutocompleteOptions &&
+ !autocompleteOptions?.some(
+ (option) => option.text === newTagText
+ )
+ ) {
+ // error
+ return;
+ }
+
+ if (validateTag && !validateTag(newTagText)) {
+ return;
+ }
+
+ if (minLength && newTagText.length < minLength) {
+ console.warn("Tag is too short");
+ // error
+ return;
+ }
+
+ // Validate maxLength
+ if (maxLength && newTagText.length > maxLength) {
+ // error
+ console.warn("Tag is too long");
+ return;
+ }
+
+ const newTagId = generateTagId();
+
+ if (
+ newTagText &&
+ (allowDuplicates ||
+ !tags.some((tag) => tag.text === newTagText)) &&
+ (maxTags === undefined || tags.length < maxTags)
+ ) {
+ setTags([...tags, { id: newTagId, text: newTagText }]);
+ onTagAdd?.(newTagText);
+ setTagCount((prevTagCount) => prevTagCount + 1);
+ }
+ setInputValue("");
+ } else {
+ switch (e.key) {
+ case "Delete":
+ if (activeTagIndex !== null) {
+ e.preventDefault();
+ const newTags = [...tags];
+ newTags.splice(activeTagIndex, 1);
+ setTags(newTags);
+ setActiveTagIndex((prev) =>
+ newTags.length === 0
+ ? null
+ : prev! >= newTags.length
+ ? newTags.length - 1
+ : prev
+ );
+ setTagCount((prevTagCount) => prevTagCount - 1);
+ onTagRemove?.(tags[activeTagIndex].text);
+ }
+ break;
+ case "Backspace":
+ if (activeTagIndex !== null) {
+ e.preventDefault();
+ const newTags = [...tags];
+ newTags.splice(activeTagIndex, 1);
+ setTags(newTags);
+ setActiveTagIndex((prev) =>
+ prev! === 0 ? null : prev! - 1
+ );
+ setTagCount((prevTagCount) => prevTagCount - 1);
+ onTagRemove?.(tags[activeTagIndex].text);
+ }
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ if (activeTagIndex === null) {
+ setActiveTagIndex(0);
+ } else {
+ setActiveTagIndex((prev) =>
+ prev! + 1 >= tags.length ? 0 : prev! + 1
+ );
+ }
+ break;
+ case "ArrowLeft":
+ e.preventDefault();
+ if (activeTagIndex === null) {
+ setActiveTagIndex(tags.length - 1);
+ } else {
+ setActiveTagIndex((prev) =>
+ prev! === 0 ? tags.length - 1 : prev! - 1
+ );
+ }
+ break;
+ case "Home":
+ e.preventDefault();
+ setActiveTagIndex(0);
+ break;
+ case "End":
+ e.preventDefault();
+ setActiveTagIndex(tags.length - 1);
+ break;
+ }
+ }
+ };
+
+ const removeTag = (idToRemove: string) => {
+ setTags(tags.filter((tag) => tag.id !== idToRemove));
+ onTagRemove?.(
+ tags.find((tag) => tag.id === idToRemove)?.text || ""
+ );
+ setTagCount((prevTagCount) => prevTagCount - 1);
+ };
+
+ const onSortEnd = (oldIndex: number, newIndex: number) => {
+ setTags((currentTags) => {
+ const newTags = [...currentTags];
+ const [removedTag] = newTags.splice(oldIndex, 1);
+ newTags.splice(newIndex, 0, removedTag);
+
+ return newTags;
+ });
+ };
+
+ const handleClearAll = () => {
+ if (!onClearAll) {
+ setActiveTagIndex(-1);
+ setTags([]);
+ return;
+ }
+ onClearAll?.();
+ };
+
+ // const filteredAutocompleteOptions = autocompleteFilter
+ // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
+ // : autocompleteOptions;
+ const filteredAutocompleteOptions = useMemo(() => {
+ return (autocompleteOptions || []).filter((option) =>
+ option.text
+ .toLowerCase()
+ .includes(inputValue ? inputValue.toLowerCase() : "")
+ );
+ }, [inputValue, autocompleteOptions]);
+
+ const displayedTags = sortTags ? [...tags].sort() : tags;
+
+ const truncatedTags = truncate
+ ? tags.map((tag) => ({
+ id: tag.id,
+ text:
+ tag.text?.length > truncate
+ ? `${tag.text.substring(0, truncate)}...`
+ : tag.text
+ }))
+ : displayedTags;
+
+ return (
+ 0 ? "gap-3" : ""} ${
+ inputFieldPosition === "bottom"
+ ? "flex-col"
+ : inputFieldPosition === "top"
+ ? "flex-col-reverse"
+ : "flex-row"
+ }`}
+ >
+ {!usePopoverForTags &&
+ (!inlineTags ? (
+
+ ) : (
+ !enableAutocomplete && (
+
+
+
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ className={cn(
+ "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
+ // className,
+ styleClasses?.input
+ )}
+ autoComplete={
+ enableAutocomplete ? "on" : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ />
+
+
+ )
+ ))}
+ {enableAutocomplete ? (
+
+
+ {!usePopoverForTags ? (
+ !inlineTags ? (
+ // = maxTags ? placeholderWhenFull : placeholder}
+ // ref={inputRef}
+ // value={inputValue}
+ // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
+ // onChangeCapture={handleInputChange}
+ // onKeyDown={handleKeyDown}
+ // onFocus={handleInputFocus}
+ // onBlur={handleInputBlur}
+ // className={cn(
+ // 'w-full',
+ // // className,
+ // styleClasses?.input,
+ // )}
+ // />
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ className={cn(
+ "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
+ // className,
+ styleClasses?.input
+ )}
+ autoComplete={
+ enableAutocomplete ? "on" : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ />
+ ) : (
+
+
+ {/* = maxTags ? placeholderWhenFull : placeholder}
+ ref={inputRef}
+ value={inputValue}
+ disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
+ onChangeCapture={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ inlineTags={inlineTags}
+ className={cn(
+ 'border-0 flex-1 w-fit h-5',
+ // className,
+ styleClasses?.input,
+ )}
+ /> */}
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ className={cn(
+ "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
+ // className,
+ styleClasses?.input
+ )}
+ autoComplete={
+ enableAutocomplete
+ ? "on"
+ : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ />
+
+ )
+ ) : (
+
+ {/* = maxTags ? placeholderWhenFull : placeholder}
+ ref={inputRef}
+ value={inputValue}
+ disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
+ onChangeCapture={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ className={cn(
+ 'w-full',
+ // className,
+ styleClasses?.input,
+ )}
+ /> */}
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ className={cn(
+ "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
+ // className,
+ styleClasses?.input
+ )}
+ autoComplete={
+ enableAutocomplete ? "on" : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ />
+
+ )}
+
+
+ ) : (
+
+ {!usePopoverForTags ? (
+ !inlineTags ? (
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ className={cn(
+ styleClasses?.input
+ // className
+ )}
+ autoComplete={
+ enableAutocomplete ? "on" : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ />
+ ) : null
+ ) : (
+
+ = maxTags
+ ? placeholderWhenFull
+ : placeholder
+ }
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ {...inputProps}
+ autoComplete={
+ enableAutocomplete ? "on" : "off"
+ }
+ list={
+ enableAutocomplete
+ ? "autocomplete-options"
+ : undefined
+ }
+ disabled={
+ disabled ||
+ (maxTags !== undefined &&
+ tags.length >= maxTags)
+ }
+ className={cn(
+ "border-0 w-full",
+ styleClasses?.input
+ // className
+ )}
+ />
+
+ )}
+
+ )}
+
+ {showCount && maxTags && (
+
+
+ {`${tagCount}`}/{`${maxTags}`}
+
+
+ )}
+ {clearAll && (
+
+ Clear All
+
+ )}
+
+ );
+ }
+);
+
+TagInput.displayName = "TagInput";
+
+export function uuid() {
+ return crypto.getRandomValues(new Uint32Array(1))[0].toString();
+}
+
+export { TagInput };
diff --git a/src/components/tags/tag-list.tsx b/src/components/tags/tag-list.tsx
new file mode 100644
index 00000000..a9e30ffe
--- /dev/null
+++ b/src/components/tags/tag-list.tsx
@@ -0,0 +1,205 @@
+import React from "react";
+import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
+import { Tag, TagProps } from "./tag";
+import SortableList, { SortableItem } from "react-easy-sort";
+import { cn } from "@app/lib/cn";
+
+export type TagListProps = {
+ tags: TagType[];
+ customTagRenderer?: (tag: TagType, isActiveTag: boolean) => React.ReactNode;
+ direction?: TagProps["direction"];
+ onSortEnd: (oldIndex: number, newIndex: number) => void;
+ className?: string;
+ inlineTags?: boolean;
+ activeTagIndex?: number | null;
+ setActiveTagIndex?: (index: number | null) => void;
+ classStyleProps: {
+ tagListClasses: TagInputStyleClassesProps["tagList"];
+ tagClasses: TagInputStyleClassesProps["tag"];
+ };
+ disabled?: boolean;
+} & Omit;
+
+const DropTarget: React.FC = () => {
+ return
;
+};
+
+export const TagList: React.FC = ({
+ tags,
+ customTagRenderer,
+ direction,
+ draggable,
+ onSortEnd,
+ className,
+ inlineTags,
+ activeTagIndex,
+ setActiveTagIndex,
+ classStyleProps,
+ disabled,
+ ...tagListProps
+}) => {
+ const [draggedTagId, setDraggedTagId] = React.useState(null);
+
+ const handleMouseDown = (id: string) => {
+ setDraggedTagId(id);
+ };
+
+ const handleMouseUp = () => {
+ setDraggedTagId(null);
+ };
+
+ return (
+ <>
+ {!inlineTags ? (
+
+ {draggable ? (
+
}
+ >
+ {tags.map((tagObj, index) => (
+
+
+ handleMouseDown(tagObj.id)
+ }
+ onMouseLeave={handleMouseUp}
+ className={cn(
+ {
+ "border border-solid border-primary rounded-md":
+ draggedTagId === tagObj.id
+ },
+ "transition-all duration-200 ease-in-out"
+ )}
+ >
+ {customTagRenderer ? (
+ customTagRenderer(
+ tagObj,
+ index === activeTagIndex
+ )
+ ) : (
+
+ )}
+
+
+ ))}
+
+ ) : (
+ tags.map((tagObj, index) =>
+ customTagRenderer ? (
+ customTagRenderer(
+ tagObj,
+ index === activeTagIndex
+ )
+ ) : (
+
+ )
+ )
+ )}
+
+ ) : (
+ <>
+ {draggable ? (
+ }
+ >
+ {tags.map((tagObj, index) => (
+
+
+ handleMouseDown(tagObj.id)
+ }
+ onMouseLeave={handleMouseUp}
+ className={cn(
+ {
+ "border border-solid border-primary rounded-md":
+ draggedTagId === tagObj.id
+ },
+ "transition-all duration-200 ease-in-out"
+ )}
+ >
+ {customTagRenderer ? (
+ customTagRenderer(
+ tagObj,
+ index === activeTagIndex
+ )
+ ) : (
+
+ )}
+
+
+ ))}
+
+ ) : (
+ tags.map((tagObj, index) =>
+ customTagRenderer ? (
+ customTagRenderer(
+ tagObj,
+ index === activeTagIndex
+ )
+ ) : (
+
+ )
+ )
+ )}
+ >
+ )}
+ >
+ );
+};
diff --git a/src/components/tags/tag-popover.tsx b/src/components/tags/tag-popover.tsx
new file mode 100644
index 00000000..6145b498
--- /dev/null
+++ b/src/components/tags/tag-popover.tsx
@@ -0,0 +1,207 @@
+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 = ({
+ children,
+ tags,
+ customTagRenderer,
+ activeTagIndex,
+ setActiveTagIndex,
+ classStyleProps,
+ disabled,
+ usePortal,
+ ...tagProps
+}) => {
+ const triggerContainerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const popoverContentRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const [popoverWidth, setPopoverWidth] = useState(0);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [inputFocused, setInputFocused] = useState(false);
+ const [sideOffset, setSideOffset] = useState(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
+ | React.FocusEvent
+ ) => {
+ // 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).props.onFocus;
+ if (userOnFocus) userOnFocus(event);
+ };
+
+ const handleInputBlur = (
+ event:
+ | React.FocusEvent
+ | React.FocusEvent
+ ) => {
+ setInputFocused(false);
+
+ // Allow the popover to close if no other interactions keep it open
+ if (!isPopoverOpen) {
+ setIsPopoverOpen(false);
+ }
+
+ const userOnBlur = (children as React.ReactElement).props.onBlur;
+ if (userOnBlur) userOnBlur(event);
+ };
+
+ return (
+
+
+ {React.cloneElement(children as React.ReactElement
, {
+ onFocus: handleInputFocus,
+ onBlur: handleInputBlur,
+ ref: inputRef
+ })}
+
+ setIsPopoverOpen(!isPopoverOpen)}
+ >
+
+
+
+
+
+
+
+
+
+ Entered Tags
+
+
+ These are the tags you've entered.
+
+
+
+
+
+ );
+};
diff --git a/src/components/tags/tag.tsx b/src/components/tags/tag.tsx
new file mode 100644
index 00000000..e3e4f838
--- /dev/null
+++ b/src/components/tags/tag.tsx
@@ -0,0 +1,169 @@
+import React from "react";
+import { Button } from "../ui/button";
+import {
+ TagInputProps,
+ TagInputStyleClassesProps,
+ type Tag as TagType
+} from "./tag-input";
+
+import { cva } from "class-variance-authority";
+import { cn } from "@app/lib/cn";
+
+export const tagVariants = cva(
+ "transition-all border inline-flex items-center text-sm pl-2 rounded-md",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50",
+ primary:
+ "bg-primary border-primary text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50",
+ destructive:
+ "bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
+ },
+ size: {
+ sm: "text-xs h-7",
+ md: "text-sm h-8",
+ lg: "text-base h-9",
+ xl: "text-lg h-10"
+ },
+ shape: {
+ default: "rounded-sm",
+ rounded: "rounded-lg",
+ square: "rounded-none",
+ pill: "rounded-full"
+ },
+ borderStyle: {
+ default: "border-solid",
+ none: "border-none",
+ dashed: "border-dashed",
+ dotted: "border-dotted",
+ double: "border-double"
+ },
+ textCase: {
+ uppercase: "uppercase",
+ lowercase: "lowercase",
+ capitalize: "capitalize"
+ },
+ interaction: {
+ clickable: "cursor-pointer hover:shadow-md",
+ nonClickable: "cursor-default"
+ },
+ animation: {
+ none: "",
+ fadeIn: "animate-fadeIn",
+ slideIn: "animate-slideIn",
+ bounce: "animate-bounce"
+ },
+ textStyle: {
+ normal: "font-normal",
+ bold: "font-bold",
+ italic: "italic",
+ underline: "underline",
+ lineThrough: "line-through"
+ }
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ shape: "default",
+ borderStyle: "default",
+ interaction: "nonClickable",
+ animation: "fadeIn",
+ textStyle: "normal"
+ }
+ }
+);
+
+export type TagProps = {
+ tagObj: TagType;
+ variant: TagInputProps["variant"];
+ size: TagInputProps["size"];
+ shape: TagInputProps["shape"];
+ borderStyle: TagInputProps["borderStyle"];
+ textCase: TagInputProps["textCase"];
+ interaction: TagInputProps["interaction"];
+ animation: TagInputProps["animation"];
+ textStyle: TagInputProps["textStyle"];
+ onRemoveTag: (id: string) => void;
+ isActiveTag?: boolean;
+ tagClasses?: TagInputStyleClassesProps["tag"];
+ disabled?: boolean;
+} & Pick;
+
+export const Tag: React.FC = ({
+ tagObj,
+ direction,
+ draggable,
+ onTagClick,
+ onRemoveTag,
+ variant,
+ size,
+ shape,
+ borderStyle,
+ textCase,
+ interaction,
+ animation,
+ textStyle,
+ isActiveTag,
+ tagClasses,
+ disabled
+}) => {
+ return (
+ onTagClick?.(tagObj)}
+ >
+ {tagObj.text}
+ {
+ e.stopPropagation(); // Prevent event from bubbling up to the tag span
+ onRemoveTag(tagObj.id);
+ }}
+ disabled={disabled}
+ className={cn(
+ `py-1 px-3 h-full hover:bg-transparent`,
+ tagClasses?.closeButton
+ )}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 3aa288a9..0afd01e1 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -15,9 +15,9 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
- "border border-input bg-card hover:bg-accent hover:text-accent-foreground",
+ "border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground",
secondary:
- "bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
+ "bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
text: "",
link: "text-primary underline-offset-4 hover:underline",
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 9a085cb2..e10e2589 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -15,7 +15,7 @@ const Input = React.forwardRef(
(
span]:line-clamp-1",
+ "flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
"rounded-md"
)}