mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-23 20:24:34 +02:00
small visual improvements
This commit is contained in:
parent
de70c62ea8
commit
20f1a6372b
31 changed files with 1976 additions and 136 deletions
|
@ -50,7 +50,6 @@
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"emblor": "1.4.7",
|
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
|
@ -71,6 +70,7 @@
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
"react-easy-sort": "^1.6.0",
|
||||||
"react-hook-form": "7.54.2",
|
"react-hook-form": "7.54.2",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
|
|
|
@ -136,7 +136,6 @@ export default function CreateRoleForm({
|
||||||
<FormLabel>Role Name</FormLabel>
|
<FormLabel>Role Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter name for the role"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -152,7 +151,6 @@ export default function CreateRoleForm({
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Describe the role"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -195,7 +195,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter an email"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -210,11 +210,11 @@ export default function GeneralPage() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
org
|
organization.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -238,7 +238,6 @@ export default function GeneralPage() {
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
<AlertTriangle className="h-5 w-5" />
|
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
|
|
|
@ -289,28 +289,6 @@ export default function CreateResourceForm({
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-resource-form"
|
id="create-resource-form"
|
||||||
>
|
>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Resource name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is the name that will
|
|
||||||
be displayed for this
|
|
||||||
resource.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!env.flags.allowRawResources || (
|
{!env.flags.allowRawResources || (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@ -343,6 +321,24 @@ export default function CreateResourceForm({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is display name for the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{form.watch("http") &&
|
{form.watch("http") &&
|
||||||
env.flags.allowBaseDomainResources && (
|
env.flags.allowBaseDomainResources && (
|
||||||
<div>
|
<div>
|
||||||
|
@ -392,7 +388,7 @@ export default function CreateResourceForm({
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="w-1/2 mr-1">
|
<div className="w-full mr-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
form.control
|
||||||
|
@ -405,13 +401,13 @@ export default function CreateResourceForm({
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
className="text-right"
|
className="text-right"
|
||||||
placeholder="Subdomain"
|
placeholder="Enter subdomain"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2">
|
<div className="max-w-1/2">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
form.control
|
||||||
|
@ -560,11 +556,11 @@ export default function CreateResourceForm({
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The protocol to use
|
The protocol to use
|
||||||
for the resource
|
for the resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -579,7 +575,6 @@ export default function CreateResourceForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter port number"
|
|
||||||
value={
|
value={
|
||||||
field.value ??
|
field.value ??
|
||||||
""
|
""
|
||||||
|
@ -598,13 +593,13 @@ export default function CreateResourceForm({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The port number to
|
The port number to
|
||||||
proxy requests to
|
proxy requests to
|
||||||
(required for
|
(required for
|
||||||
non-HTTP resources)
|
non-HTTP resources).
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -644,7 +639,7 @@ export default function CreateResourceForm({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search site..." />
|
<CommandInput placeholder="Search site" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No site
|
No site
|
||||||
|
@ -687,11 +682,12 @@ export default function CreateResourceForm({
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<FormDescription>
|
|
||||||
This is the site that will
|
|
||||||
be used in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This site will provide
|
||||||
|
connectivity to the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -42,7 +42,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
This resource is protected with
|
This resource is protected with
|
||||||
at least one auth method.
|
at least one authentication method.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -136,17 +136,16 @@ export default function SetResourcePasswordForm({
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Your secure password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
password. It must be at least 4
|
password. It must be at least 4
|
||||||
characters long.
|
characters long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -167,13 +167,13 @@ export default function SetResourcePincodeForm({
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Users will be able to access
|
Users will be able to access
|
||||||
this resource by entering this
|
this resource by entering this
|
||||||
PIN code. It must be at least 6
|
PIN code. It must be at least 6
|
||||||
digits long.
|
digits long.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { set, z } from "zod";
|
import { set, z } from "zod";
|
||||||
import { Tag } from "emblor";
|
// import { Tag } from "emblor";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
|
@ -27,7 +27,7 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { TagInput } from "emblor";
|
// import { TagInput } from "emblor";
|
||||||
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
import { ListUsersResponse } from "@server/routers/user";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
@ -49,6 +49,7 @@ import {
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
|
@ -429,7 +430,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Roles</FormLabel>
|
<FormLabel>Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
|
@ -438,7 +438,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveRolesTagIndex
|
setActiveRolesTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a role"
|
placeholder="Select a role"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.roles
|
.roles
|
||||||
|
@ -477,13 +477,11 @@ export default function ResourceAuthenticationPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
These roles will be able
|
|
||||||
to access this resource.
|
|
||||||
Admins can always access
|
Admins can always access
|
||||||
this resource.
|
this resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -494,7 +492,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Users</FormLabel>
|
<FormLabel>Users</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
|
@ -503,7 +500,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveUsersTagIndex
|
setActiveUsersTagIndex
|
||||||
}
|
}
|
||||||
placeholder="Enter a user"
|
placeholder="Select a user"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.users
|
.users
|
||||||
|
@ -542,15 +539,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
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.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -732,7 +720,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
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.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -483,8 +483,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
SSL Configuration
|
SSL Configuration
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Setup SSL to secure your connections with
|
Setup SSL to secure your connections with Let's Encrypt certificates
|
||||||
LetsEncrypt certificates
|
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
|
|
@ -291,11 +291,11 @@ export default function GeneralForm() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
resource.
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -348,7 +348,7 @@ export default function GeneralForm() {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="w-1/2 mr-1">
|
<div className="w-full mr-1">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
form.control
|
||||||
|
@ -357,17 +357,20 @@ export default function GeneralForm() {
|
||||||
render={({
|
render={({
|
||||||
field
|
field
|
||||||
}) => (
|
}) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
className="text-right"
|
{...field}
|
||||||
placeholder="Subdomain"
|
className="text-right"
|
||||||
/>
|
placeholder="Enter subdomain"
|
||||||
</FormControl>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2">
|
<div className="max-w-1/2">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
form.control
|
||||||
|
@ -484,7 +487,6 @@ export default function GeneralForm() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter port number"
|
|
||||||
value={
|
value={
|
||||||
field.value ?? ""
|
field.value ?? ""
|
||||||
}
|
}
|
||||||
|
@ -501,12 +503,12 @@ export default function GeneralForm() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the port that will
|
This is the port that will
|
||||||
be used to access the
|
be used to access the
|
||||||
resource.
|
resource.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -583,7 +585,7 @@ export default function GeneralForm() {
|
||||||
<PopoverContent className="w-full p-0">
|
<PopoverContent className="w-full p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search sites..."
|
placeholder="Search sites"
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
|
@ -626,10 +628,6 @@ export default function GeneralForm() {
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<FormDescription>
|
|
||||||
Select the new site to transfer
|
|
||||||
this resource to.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -645,7 +643,6 @@ export default function GeneralForm() {
|
||||||
loading={transferLoading}
|
loading={transferLoading}
|
||||||
disabled={transferLoading}
|
disabled={transferLoading}
|
||||||
form="transfer-form"
|
form="transfer-form"
|
||||||
variant="destructive"
|
|
||||||
>
|
>
|
||||||
Transfer Resource
|
Transfer Resource
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -305,7 +305,7 @@ export default function CreateShareLinkForm({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search resources..." />
|
<CommandInput placeholder="Search resources" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No
|
No
|
||||||
|
@ -374,7 +374,6 @@ export default function CreateShareLinkForm({
|
||||||
</Label>
|
</Label>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter title"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -437,7 +436,6 @@ export default function CreateShareLinkForm({
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="Enter duration"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -272,17 +272,13 @@ PersistentKeepalive = 5`
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input autoComplete="off" {...field} />
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Site name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This is the name that will be displayed for
|
|
||||||
this site.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is the the display name for the
|
||||||
|
site.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -319,10 +315,10 @@ PersistentKeepalive = 5`
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is how you will expose connections.
|
This is how you will expose connections.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -354,7 +350,7 @@ PersistentKeepalive = 5`
|
||||||
) : form.watch("method") === "wireguard" &&
|
) : form.watch("method") === "wireguard" &&
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<p>Loading WireGuard configuration...</p>
|
<p>Loading WireGuard configuration...</p>
|
||||||
) : form.watch("method") === "newt" ? (
|
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
|
@ -376,8 +372,8 @@ PersistentKeepalive = 5`
|
||||||
className="p-0 flex items-center justify-between w-full"
|
className="p-0 flex items-center justify-between w-full"
|
||||||
>
|
>
|
||||||
<h4 className="text-sm font-semibold">
|
<h4 className="text-sm font-semibold">
|
||||||
Expand for Docker Deployment
|
Expand for Docker
|
||||||
Details
|
Deployment Details
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<ChevronsUpDown className="h-4 w-4" />
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
|
|
|
@ -33,7 +33,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string().nonempty("Name is required")
|
||||||
});
|
});
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
@ -114,11 +114,11 @@ export default function GeneralPage() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
This is the display name of the
|
This is the display name of the
|
||||||
site
|
site.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -38,7 +38,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
@ -223,16 +223,13 @@ export default function ResetPasswordForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
placeholder="Enter your email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We'll send a password reset
|
We'll send a password reset
|
||||||
code to this email address.
|
code to this email address.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -255,7 +252,6 @@ export default function ResetPasswordForm({
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -276,12 +272,15 @@ export default function ResetPasswordForm({
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter reset code sent to your email"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Check your email for the
|
||||||
|
reset code.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -298,7 +297,6 @@ export default function ResetPasswordForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -317,7 +315,6 @@ export default function ResetPasswordForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -349,7 +346,9 @@ export default function ResetPasswordForm({
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
{...field}
|
{...field}
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
|
|
|
@ -449,7 +449,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter password"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
@ -518,7 +517,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter email"
|
|
||||||
type="email"
|
type="email"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
@ -577,7 +575,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter OTP"
|
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -145,7 +145,7 @@ export default function SignupForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Email" {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -160,7 +160,6 @@ export default function SignupForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -177,7 +176,6 @@ export default function SignupForm({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm Password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -145,7 +145,6 @@ export default function VerifyEmailForm({
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email"
|
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
|
@ -196,12 +195,12 @@ export default function VerifyEmailForm({
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
We sent a verification code to your
|
We sent a verification code to your
|
||||||
email address. Please enter the code
|
email address. Please enter the code
|
||||||
to verify your email address.
|
to verify your email address.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -200,7 +200,6 @@ export default function StepperForm() {
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Name your new organization"
|
|
||||||
type="text"
|
type="text"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
@ -242,7 +241,6 @@ export default function StepperForm() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter unique organization ID"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -135,7 +135,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -200,7 +200,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -246,7 +245,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="code"
|
type="code"
|
||||||
placeholder="Enter the 6-digit code from your authenticator app"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -147,7 +147,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter your email"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -166,7 +165,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
353
src/components/tags/autocomplete.tsx
Normal file
353
src/components/tags/autocomplete.tsx
Normal file
|
@ -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<React.SetStateAction<TagType[]>>;
|
||||||
|
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setTagCount: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
autocompleteOptions: TagType[];
|
||||||
|
maxTags?: number;
|
||||||
|
onTagAdd?: (tag: string) => void;
|
||||||
|
onTagRemove?: (tag: string) => void;
|
||||||
|
allowDuplicates: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inlineTags?: boolean;
|
||||||
|
classStyleProps: TagInputStyleClassesProps["autoComplete"];
|
||||||
|
usePortal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||||
|
tags,
|
||||||
|
setTags,
|
||||||
|
setInputValue,
|
||||||
|
setTagCount,
|
||||||
|
autocompleteOptions,
|
||||||
|
maxTags,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
allowDuplicates,
|
||||||
|
inlineTags,
|
||||||
|
children,
|
||||||
|
classStyleProps,
|
||||||
|
usePortal
|
||||||
|
}) => {
|
||||||
|
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [popooverContentTop, setPopoverContentTop] = useState<number>(0);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
// Dynamically calculate the top position for the popover content
|
||||||
|
useEffect(() => {
|
||||||
|
if (!triggerContainerRef.current || !triggerRef.current) return;
|
||||||
|
setPopoverContentTop(
|
||||||
|
triggerContainerRef.current?.getBoundingClientRect().bottom -
|
||||||
|
triggerRef.current?.getBoundingClientRect().bottom
|
||||||
|
);
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
// Close the popover when clicking outside of it
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOutsideClick = (
|
||||||
|
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
isPopoverOpen &&
|
||||||
|
triggerContainerRef.current &&
|
||||||
|
popoverContentRef.current &&
|
||||||
|
!triggerContainerRef.current.contains(event.target as Node) &&
|
||||||
|
!popoverContentRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (open && triggerContainerRef.current) {
|
||||||
|
const { width } =
|
||||||
|
triggerContainerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputFocused]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputFocus = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
if (triggerContainerRef.current) {
|
||||||
|
const { width } =
|
||||||
|
triggerContainerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverWidth(width);
|
||||||
|
setIsPopoverOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set inputFocused to true if the popover is already open.
|
||||||
|
// This will prevent the popover from opening due to an input focus if it was initially closed.
|
||||||
|
if (isPopoverOpen) {
|
||||||
|
setInputFocused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
|
||||||
|
if (userOnFocus) userOnFocus(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setInputFocused(false);
|
||||||
|
|
||||||
|
// Allow the popover to close if no other interactions keep it open
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
|
||||||
|
if (userOnBlur) userOnBlur(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!isPopoverOpen) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prevIndex) =>
|
||||||
|
prevIndex <= 0
|
||||||
|
? autocompleteOptions.length - 1
|
||||||
|
: prevIndex - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prevIndex) =>
|
||||||
|
prevIndex === autocompleteOptions.length - 1
|
||||||
|
? 0
|
||||||
|
: prevIndex + 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedIndex !== -1) {
|
||||||
|
toggleTag(autocompleteOptions[selectedIndex]);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (option: TagType) => {
|
||||||
|
// Check if the tag already exists in the array
|
||||||
|
const index = tags.findIndex((tag) => tag.text === option.text);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
// Tag exists, remove it
|
||||||
|
const newTags = tags.filter((_, i) => i !== index);
|
||||||
|
setTags(newTags);
|
||||||
|
setTagCount((prevCount) => prevCount - 1);
|
||||||
|
if (onTagRemove) {
|
||||||
|
onTagRemove(option.text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tag doesn't exist, add it if allowed
|
||||||
|
if (
|
||||||
|
!allowDuplicates &&
|
||||||
|
tags.some((tag) => tag.text === option.text)
|
||||||
|
) {
|
||||||
|
// If duplicates aren't allowed and a tag with the same text exists, do nothing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the tag if it doesn't exceed max tags, if applicable
|
||||||
|
if (!maxTags || tags.length < maxTags) {
|
||||||
|
setTags([...tags, option]);
|
||||||
|
setTagCount((prevCount) => prevCount + 1);
|
||||||
|
setInputValue("");
|
||||||
|
if (onTagAdd) {
|
||||||
|
onTagAdd(option.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const childrenWithProps = React.cloneElement(
|
||||||
|
children as React.ReactElement<any>,
|
||||||
|
{
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
onFocus: handleInputFocus,
|
||||||
|
onBlur: handleInputBlur,
|
||||||
|
ref: inputRef
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
classStyleProps?.command
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
modal={usePortal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative h-full flex items-center rounded-md border-2 bg-transparent pr-3"
|
||||||
|
ref={triggerContainerRef}
|
||||||
|
>
|
||||||
|
{childrenWithProps}
|
||||||
|
<PopoverTrigger asChild ref={triggerRef}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
`hover:bg-transparent ${!inlineTags ? "ml-auto" : ""}`,
|
||||||
|
classStyleProps?.popoverTrigger
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPopoverOpen(!isPopoverOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
<PopoverContent
|
||||||
|
ref={popoverContentRef}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
forceMount
|
||||||
|
className={cn(
|
||||||
|
`p-0 relative`,
|
||||||
|
classStyleProps?.popoverContent
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${popooverContentTop}px`,
|
||||||
|
marginLeft: `calc(-${popoverWidth}px + 36px)`,
|
||||||
|
width: `${popoverWidth}px`,
|
||||||
|
minWidth: `${popoverWidth}px`,
|
||||||
|
zIndex: 9999
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||||
|
classStyleProps?.commandList
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
minHeight: "68px"
|
||||||
|
}}
|
||||||
|
key={autocompleteOptions.length}
|
||||||
|
>
|
||||||
|
{autocompleteOptions.length > 0 ? (
|
||||||
|
<div
|
||||||
|
key={autocompleteOptions.length}
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-y-auto overflow-hidden p-1 text-foreground",
|
||||||
|
classStyleProps?.commandGroup
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
minHeight: "68px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground font-medium text-sm py-1.5 px-2 pb-2">
|
||||||
|
Suggestions
|
||||||
|
</span>
|
||||||
|
<div role="separator" className="py-0.5" />
|
||||||
|
{autocompleteOptions.map((option, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent",
|
||||||
|
isSelected &&
|
||||||
|
"bg-accent text-accent-foreground",
|
||||||
|
classStyleProps?.commandItem
|
||||||
|
)}
|
||||||
|
data-value={option.text}
|
||||||
|
onClick={() => toggleTag(option)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
{option.text}
|
||||||
|
{tags.some(
|
||||||
|
(tag) =>
|
||||||
|
tag.text === option.text
|
||||||
|
) && (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-check"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
949
src/components/tags/tag-input.tsx
Normal file
949
src/components/tags/tag-input.tsx
Normal file
|
@ -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<HTMLInputElement>,
|
||||||
|
"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<typeof tagVariants> {
|
||||||
|
placeholder?: string;
|
||||||
|
tags: Tag[];
|
||||||
|
setTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||||
|
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<HTMLInputElement>;
|
||||||
|
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||||
|
onTagClick?: (tag: Tag) => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
inputFieldPosition?: "bottom" | "top";
|
||||||
|
clearAll?: boolean;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
restrictTagsToAutocompleteOptions?: boolean;
|
||||||
|
inlineTags?: boolean;
|
||||||
|
activeTagIndex: number | null;
|
||||||
|
setActiveTagIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
styleClasses?: TagInputStyleClassesProps;
|
||||||
|
usePortal?: boolean;
|
||||||
|
addOnPaste?: boolean;
|
||||||
|
addTagsOnBlur?: boolean;
|
||||||
|
generateTagId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||||
|
(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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
setActiveTagIndex(null); // Reset active tag index when the input field gains focus
|
||||||
|
onFocus?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${
|
||||||
|
inputFieldPosition === "bottom"
|
||||||
|
? "flex-col"
|
||||||
|
: inputFieldPosition === "top"
|
||||||
|
? "flex-col-reverse"
|
||||||
|
: "flex-row"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!usePopoverForTags &&
|
||||||
|
(!inlineTags ? (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!enableAutocomplete && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`flex flex-row flex-wrap items-center gap-2 p-2 w-full rounded-md border-2 border-input bg-background 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-2 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={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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{enableAutocomplete ? (
|
||||||
|
<div className="w-full">
|
||||||
|
<Autocomplete
|
||||||
|
tags={tags}
|
||||||
|
setTags={setTags}
|
||||||
|
setInputValue={setInputValue}
|
||||||
|
autocompleteOptions={
|
||||||
|
filteredAutocompleteOptions as Tag[]
|
||||||
|
}
|
||||||
|
setTagCount={setTagCount}
|
||||||
|
maxTags={maxTags}
|
||||||
|
onTagAdd={onTagAdd}
|
||||||
|
onTagRemove={onTagRemove}
|
||||||
|
allowDuplicates={allowDuplicates ?? false}
|
||||||
|
inlineTags={inlineTags}
|
||||||
|
usePortal={usePortal}
|
||||||
|
classStyleProps={{
|
||||||
|
command: styleClasses?.autoComplete?.command,
|
||||||
|
popoverTrigger:
|
||||||
|
styleClasses?.autoComplete?.popoverTrigger,
|
||||||
|
popoverContent:
|
||||||
|
styleClasses?.autoComplete?.popoverContent,
|
||||||
|
commandList:
|
||||||
|
styleClasses?.autoComplete?.commandList,
|
||||||
|
commandGroup:
|
||||||
|
styleClasses?.autoComplete?.commandGroup,
|
||||||
|
commandItem:
|
||||||
|
styleClasses?.autoComplete?.commandItem
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!usePopoverForTags ? (
|
||||||
|
!inlineTags ? (
|
||||||
|
// <CommandInput
|
||||||
|
// placeholder={maxTags !== undefined && tags.length >= 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,
|
||||||
|
// )}
|
||||||
|
// />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= 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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`flex flex-row flex-wrap items-center p-2 gap-2 h-fit w-full bg-background 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-2 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}
|
||||||
|
/>
|
||||||
|
{/* <CommandInput
|
||||||
|
placeholder={maxTags !== undefined && tags.length >= 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,
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= 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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<TagPopover
|
||||||
|
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}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
popoverClasses:
|
||||||
|
styleClasses?.tagPopover,
|
||||||
|
tagListClasses: styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{/* <CommandInput
|
||||||
|
placeholder={maxTags !== undefined && tags.length >= 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,
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= 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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TagPopover>
|
||||||
|
)}
|
||||||
|
</Autocomplete>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{!usePopoverForTags ? (
|
||||||
|
!inlineTags ? (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= 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
|
||||||
|
) : (
|
||||||
|
<TagPopover
|
||||||
|
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}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
popoverClasses: styleClasses?.tagPopover,
|
||||||
|
tagListClasses: styleClasses?.tagList,
|
||||||
|
tagClasses: styleClasses?.tag
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
maxTags !== undefined &&
|
||||||
|
tags.length >= 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
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TagPopover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCount && maxTags && (
|
||||||
|
<div className="flex">
|
||||||
|
<span className="text-muted-foreground text-sm mt-1 ml-auto">
|
||||||
|
{`${tagCount}`}/{`${maxTags}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{clearAll && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className={cn("mt-2", styleClasses?.clearAllButton)}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TagInput.displayName = "TagInput";
|
||||||
|
|
||||||
|
export function uuid() {
|
||||||
|
return crypto.getRandomValues(new Uint32Array(1))[0].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TagInput };
|
205
src/components/tags/tag-list.tsx
Normal file
205
src/components/tags/tag-list.tsx
Normal file
|
@ -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<TagProps, "tagObj">;
|
||||||
|
|
||||||
|
const DropTarget: React.FC = () => {
|
||||||
|
return <div className={cn("h-full rounded-md bg-secondary/50")} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagList: React.FC<TagListProps> = ({
|
||||||
|
tags,
|
||||||
|
customTagRenderer,
|
||||||
|
direction,
|
||||||
|
draggable,
|
||||||
|
onSortEnd,
|
||||||
|
className,
|
||||||
|
inlineTags,
|
||||||
|
activeTagIndex,
|
||||||
|
setActiveTagIndex,
|
||||||
|
classStyleProps,
|
||||||
|
disabled,
|
||||||
|
...tagListProps
|
||||||
|
}) => {
|
||||||
|
const [draggedTagId, setDraggedTagId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleMouseDown = (id: string) => {
|
||||||
|
setDraggedTagId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDraggedTagId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!inlineTags ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md w-full",
|
||||||
|
// className,
|
||||||
|
{
|
||||||
|
"flex flex-wrap gap-2": direction === "row",
|
||||||
|
"flex flex-col gap-2": direction === "column"
|
||||||
|
},
|
||||||
|
classStyleProps?.tagListClasses?.container
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{draggable ? (
|
||||||
|
<SortableList
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
// className="flex flex-wrap gap-2 list"
|
||||||
|
className={`flex flex-wrap gap-2 list ${classStyleProps?.tagListClasses?.sortableList}`}
|
||||||
|
dropTarget={<DropTarget />}
|
||||||
|
>
|
||||||
|
{tags.map((tagObj, index) => (
|
||||||
|
<SortableItem key={tagObj.id}>
|
||||||
|
<div
|
||||||
|
onMouseDown={() =>
|
||||||
|
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
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={
|
||||||
|
index === activeTagIndex
|
||||||
|
}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={
|
||||||
|
classStyleProps?.tagClasses
|
||||||
|
}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</SortableList>
|
||||||
|
) : (
|
||||||
|
tags.map((tagObj, index) =>
|
||||||
|
customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
key={tagObj.id}
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={index === activeTagIndex}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={classStyleProps?.tagClasses}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{draggable ? (
|
||||||
|
<SortableList
|
||||||
|
onSortEnd={onSortEnd}
|
||||||
|
className="flex flex-wrap gap-2 list"
|
||||||
|
dropTarget={<DropTarget />}
|
||||||
|
>
|
||||||
|
{tags.map((tagObj, index) => (
|
||||||
|
<SortableItem key={tagObj.id}>
|
||||||
|
<div
|
||||||
|
onMouseDown={() =>
|
||||||
|
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
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={
|
||||||
|
index === activeTagIndex
|
||||||
|
}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={
|
||||||
|
classStyleProps?.tagClasses
|
||||||
|
}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableItem>
|
||||||
|
))}
|
||||||
|
</SortableList>
|
||||||
|
) : (
|
||||||
|
tags.map((tagObj, index) =>
|
||||||
|
customTagRenderer ? (
|
||||||
|
customTagRenderer(
|
||||||
|
tagObj,
|
||||||
|
index === activeTagIndex
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
key={tagObj.id}
|
||||||
|
tagObj={tagObj}
|
||||||
|
isActiveTag={index === activeTagIndex}
|
||||||
|
direction={direction}
|
||||||
|
draggable={draggable}
|
||||||
|
tagClasses={classStyleProps?.tagClasses}
|
||||||
|
{...tagListProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
207
src/components/tags/tag-popover.tsx
Normal file
207
src/components/tags/tag-popover.tsx
Normal file
|
@ -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<TagPopoverProps> = ({
|
||||||
|
children,
|
||||||
|
tags,
|
||||||
|
customTagRenderer,
|
||||||
|
activeTagIndex,
|
||||||
|
setActiveTagIndex,
|
||||||
|
classStyleProps,
|
||||||
|
disabled,
|
||||||
|
usePortal,
|
||||||
|
...tagProps
|
||||||
|
}) => {
|
||||||
|
const triggerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const popoverContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState<number>(0);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [inputFocused, setInputFocused] = useState(false);
|
||||||
|
const [sideOffset, setSideOffset] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (triggerContainerRef.current && triggerRef.current) {
|
||||||
|
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||||
|
setSideOffset(
|
||||||
|
triggerContainerRef.current.offsetWidth -
|
||||||
|
triggerRef?.current?.offsetWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize(); // Call on mount and layout changes
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize); // Adjust on window resize
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [triggerContainerRef, triggerRef]);
|
||||||
|
|
||||||
|
// Close the popover when clicking outside of it
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOutsideClick = (
|
||||||
|
event: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
isPopoverOpen &&
|
||||||
|
triggerContainerRef.current &&
|
||||||
|
popoverContentRef.current &&
|
||||||
|
!triggerContainerRef.current.contains(event.target as Node) &&
|
||||||
|
!popoverContentRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (open && triggerContainerRef.current) {
|
||||||
|
setPopoverWidth(triggerContainerRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setIsPopoverOpen(open);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputFocused]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputFocus = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
// Only set inputFocused to true if the popover is already open.
|
||||||
|
// This will prevent the popover from opening due to an input focus if it was initially closed.
|
||||||
|
if (isPopoverOpen) {
|
||||||
|
setInputFocused(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnFocus = (children as React.ReactElement<any>).props.onFocus;
|
||||||
|
if (userOnFocus) userOnFocus(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = (
|
||||||
|
event:
|
||||||
|
| React.FocusEvent<HTMLInputElement>
|
||||||
|
| React.FocusEvent<HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setInputFocused(false);
|
||||||
|
|
||||||
|
// Allow the popover to close if no other interactions keep it open
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOnBlur = (children as React.ReactElement<any>).props.onBlur;
|
||||||
|
if (userOnBlur) userOnBlur(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
modal={usePortal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex items-center rounded-md border border-input bg-transparent pr-3"
|
||||||
|
ref={triggerContainerRef}
|
||||||
|
>
|
||||||
|
{React.cloneElement(children as React.ReactElement<any>, {
|
||||||
|
onFocus: handleInputFocus,
|
||||||
|
onBlur: handleInputBlur,
|
||||||
|
ref: inputRef
|
||||||
|
})}
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
`hover:bg-transparent`,
|
||||||
|
classStyleProps?.popoverClasses?.popoverTrigger
|
||||||
|
)}
|
||||||
|
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={`lucide lucide-chevron-down h-4 w-4 shrink-0 opacity-50 ${isPopoverOpen ? "rotate-180" : "rotate-0"}`}
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
<PopoverContent
|
||||||
|
ref={popoverContentRef}
|
||||||
|
className={cn(
|
||||||
|
`w-full space-y-3`,
|
||||||
|
classStyleProps?.popoverClasses?.popoverContent
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
marginLeft: `-${sideOffset}px`,
|
||||||
|
width: `${popoverWidth}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium leading-none">
|
||||||
|
Entered Tags
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foregrounsd text-left">
|
||||||
|
These are the tags you've entered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TagList
|
||||||
|
tags={tags}
|
||||||
|
customTagRenderer={customTagRenderer}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
classStyleProps={{
|
||||||
|
tagListClasses: classStyleProps?.tagListClasses,
|
||||||
|
tagClasses: classStyleProps?.tagClasses
|
||||||
|
}}
|
||||||
|
{...tagProps}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
169
src/components/tags/tag.tsx
Normal file
169
src/components/tags/tag.tsx
Normal file
|
@ -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<TagInputProps, "direction" | "onTagClick" | "draggable">;
|
||||||
|
|
||||||
|
export const Tag: React.FC<TagProps> = ({
|
||||||
|
tagObj,
|
||||||
|
direction,
|
||||||
|
draggable,
|
||||||
|
onTagClick,
|
||||||
|
onRemoveTag,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
shape,
|
||||||
|
borderStyle,
|
||||||
|
textCase,
|
||||||
|
interaction,
|
||||||
|
animation,
|
||||||
|
textStyle,
|
||||||
|
isActiveTag,
|
||||||
|
tagClasses,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tagObj.id}
|
||||||
|
draggable={draggable}
|
||||||
|
className={cn(
|
||||||
|
tagVariants({
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
shape,
|
||||||
|
borderStyle,
|
||||||
|
textCase,
|
||||||
|
interaction,
|
||||||
|
animation,
|
||||||
|
textStyle
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
"justify-between w-full": direction === "column",
|
||||||
|
"cursor-pointer": draggable,
|
||||||
|
"ring-ring ring-offset-2 ring-2 ring-offset-background":
|
||||||
|
isActiveTag
|
||||||
|
},
|
||||||
|
tagClasses?.body
|
||||||
|
)}
|
||||||
|
onClick={() => onTagClick?.(tagObj)}
|
||||||
|
>
|
||||||
|
{tagObj.text}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="lucide lucide-x"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18"></path>
|
||||||
|
<path d="m6 6 12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
|
@ -15,9 +15,9 @@ const buttonVariants = cva(
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
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:
|
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",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
text: "",
|
text: "",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
|
|
@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
|
||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"aspect-square h-4 w-4 rounded-full border-2 border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between border 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",
|
"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,
|
className,
|
||||||
"rounded-md"
|
"rounded-md"
|
||||||
)}
|
)}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue