diff --git a/package.json b/package.json index 74f75f79..4bcb2d40 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "cookie-parser": "1.4.7", "cors": "2.8.5", "drizzle-orm": "0.38.3", - "emblor": "1.4.7", "eslint": "9.17.0", "eslint-config-next": "15.1.3", "express": "4.21.2", @@ -71,6 +70,7 @@ "qrcode.react": "4.2.0", "react": "19.0.0", "react-dom": "19.0.0", + "react-easy-sort": "^1.6.0", "react-hook-form": "7.54.2", "rebuild": "0.1.2", "semver": "7.6.3", diff --git a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx index cd9aecc5..d95f3e20 100644 --- a/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/CreateRoleForm.tsx @@ -136,7 +136,6 @@ export default function CreateRoleForm({ Role Name @@ -152,7 +151,6 @@ export default function CreateRoleForm({ Description diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index c812717d..c629c0bc 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -195,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { Email diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c2ac225c..959a32a6 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -210,11 +210,11 @@ export default function GeneralPage() { + This is the display name of the - org + organization. - )} /> @@ -238,7 +238,6 @@ export default function GeneralPage() { - Danger Zone diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index e7f4f763..ea8542f6 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -289,28 +289,6 @@ export default function CreateResourceForm({ className="space-y-4" id="create-resource-form" > - ( - - Name - - - - - This is the name that will - be displayed for this - resource. - - - - )} - /> - {!env.flags.allowRawResources || ( )} + ( + + Name + + + + + + This is display name for the + resource. + + + )} + /> + {form.watch("http") && env.flags.allowBaseDomainResources && (
@@ -392,7 +388,7 @@ export default function CreateResourceForm({ )}
-
+
)} />
-
+
+ The protocol to use - for the resource + for the resource. - )} /> @@ -579,7 +575,6 @@ export default function CreateResourceForm({ + The port number to proxy requests to (required for - non-HTTP resources) + non-HTTP resources). - )} /> @@ -644,7 +639,7 @@ export default function CreateResourceForm({ - + No site @@ -687,11 +682,12 @@ export default function CreateResourceForm({ - - This is the site that will - be used in the dashboard. - + + This site will provide + connectivity to the + resource. + )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index ab135db7..89b6d050 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -42,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { This resource is protected with - at least one auth method. + at least one authentication method.
) : ( 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} + + + +
+ +
+ {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 && ( + + )} +
+ ); + } +); + +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 + })} + + + +
+ +
+

+ 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} + + + ); +}; 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" )}