Add calendar and label components; enhance JSON generator with date selection and script path updates for installation methods

This commit is contained in:
Bram Suurd 2024-11-13 18:34:10 +01:00
parent bdce4f778d
commit a3b2a476c1
7 changed files with 848 additions and 149 deletions

View File

@ -13,7 +13,9 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
@ -24,6 +26,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.11",
"fuse.js": "^7.0.0",
"lucide-react": "^0.453.0",
@ -35,6 +38,7 @@
"prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028",
"react-code-blocks": "^0.1.6",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1",
@ -1371,6 +1375,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz",
"integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz",
@ -1447,6 +1474,43 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
"integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@ -3016,6 +3080,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -6116,6 +6190,20 @@
"react": ">=16"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0-rc-02c0e824-20241028",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-02c0e824-20241028.tgz",

View File

@ -23,7 +23,9 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
@ -34,6 +36,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.11",
"fuse.js": "^7.0.0",
"lucide-react": "^0.453.0",
@ -45,6 +48,7 @@
"prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028",
"react-code-blocks": "^0.1.6",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1",

View File

@ -2,7 +2,9 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -14,9 +16,13 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetchCategories } from "@/lib/data";
import { Category } from "@/lib/types";
import { PlusCircle, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { CalendarIcon, Check, Clipboard, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { format } from "date-fns";
import { Label } from "@/components/ui/label";
const scriptSchema = z.object({
name: z.string().min(1),
@ -96,13 +102,14 @@ export default function JSONGenerator() {
},
],
});
const [isCopied, setIsCopied] = useState(false)
const [isValid, setIsValid] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
fetchCategories()
.then((data: Category[]) => {
.then((data) => {
setCategories(data);
})
.catch((error) => console.error("Error fetching categories:", error));
@ -111,6 +118,18 @@ export default function JSONGenerator() {
const updateScript = (key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
// Update script paths for install methods if `type` or `slug` changed
if (key === "type" || key === "slug") {
updated.install_methods = updated.install_methods.map((method) => ({
...method,
script:
method.type === "alpine"
? `/${updated.type}/alpine-${updated.slug}.sh`
: `/${updated.type}/${updated.slug}.sh`,
}));
}
const result = scriptSchema.safeParse(updated);
setIsValid(result.success);
return updated;
@ -118,13 +137,10 @@ export default function JSONGenerator() {
};
const addInstallMethod = () => {
setScript((prev) => ({
...prev,
install_methods: [
...prev.install_methods,
{
type: "default",
script: "",
setScript((prev) => {
const method = {
type: "default" as "default", // Ensure type matches the union type
script: `/${prev.type}/${prev.slug}.sh`, // Default script path
resources: {
cpu: null,
ram: null,
@ -132,9 +148,12 @@ export default function JSONGenerator() {
os: null,
version: null,
},
},
],
}));
};
return {
...prev,
install_methods: [...prev.install_methods, method],
};
});
};
const updateInstallMethod = (
@ -143,12 +162,28 @@ export default function JSONGenerator() {
value: Script["install_methods"][number][keyof Script["install_methods"][number]],
) => {
setScript((prev) => {
const updatedMethods = prev.install_methods.map((method, i) => {
if (i === index) {
const updatedMethod = { ...method, [key]: value };
// Update script path if `type` of the install method changes
if (key === "type") {
updatedMethod.script =
value === "alpine"
? `/${prev.type}/alpine-${prev.slug}.sh`
: `/${prev.type}/${prev.slug}.sh`;
}
return updatedMethod;
}
return method;
});
const updated = {
...prev,
install_methods: prev.install_methods.map((method, i) =>
i === index ? { ...method, [key]: value } : method,
),
install_methods: updatedMethods,
};
const result = scriptSchema.safeParse(updated);
setIsValid(result.success);
return updated;
@ -209,10 +244,11 @@ export default function JSONGenerator() {
};
return (
<div className="flex h-screen">
<div className="flex h-screen mt-20">
<div className="w-1/2 p-4 overflow-y-auto">
<h2 className="text-2xl font-bold mb-4">JSON Generator</h2>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
placeholder="Name"
value={script.name}
@ -223,10 +259,18 @@ export default function JSONGenerator() {
value={script.slug}
onChange={(e) => updateScript("slug", e.target.value)}
/>
</div>
<Input
placeholder="Logo URL"
value={script.logo || ""}
onChange={(e) => updateScript("logo", e.target.value || null)}
/>
<Textarea
placeholder="Description"
value={script.description}
onChange={(e) => updateScript("description", e.target.value)}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Categories
</label>
<Select onValueChange={(value) => addCategory(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
@ -239,7 +283,12 @@ export default function JSONGenerator() {
))}
</SelectContent>
</Select>
<div className="mt-2 flex flex-wrap gap-2">
<div
className={cn(
"flex flex-wrap gap-2",
script.categories.length !== 0 && "mt-2",
)}
>
{script.categories.map((categoryId) => {
const category = categories.find((c) => c.id === categoryId);
return category ? (
@ -274,38 +323,78 @@ export default function JSONGenerator() {
})}
</div>
</div>
<Input
placeholder="Date Created (YYYY-MM-DD)"
value={script.date_created}
onChange={(e) => updateScript("date_created", e.target.value)}
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Date Created</Label>
<Popover>
<PopoverTrigger asChild className="flex-1">
<Button
variant={"outline"}
className={cn(
"pl-3 text-left font-normal w-full",
!script.date_created && "text-muted-foreground",
)}
>
{script.date_created ? (
format(script.date_created, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={new Date(script.date_created)}
onSelect={(date) =>
updateScript(
"date_created",
format(date || new Date(), "yyyy-MM-dd"),
)
}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-2 w-full">
<Label>Type</Label>
<Select
value={script.type}
onValueChange={(value) => updateScript("type", value)}
>
<SelectTrigger>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vm">VM</SelectItem>
<SelectItem value="ct">CT</SelectItem>
<SelectItem value="misc">Misc</SelectItem>
<SelectItem value="vm">Virtual Machine</SelectItem>
<SelectItem value="ct">LXC Container</SelectItem>
<SelectItem value="misc">Miscellaneous</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="w-full flex gap-5">
<div className="flex items-center space-x-2">
<Switch
checked={script.updateable}
onCheckedChange={(checked) => updateScript("updateable", checked)}
onCheckedChange={(checked) =>
updateScript("updateable", checked)
}
/>
<label>Updateable</label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={script.privileged}
onCheckedChange={(checked) => updateScript("privileged", checked)}
onCheckedChange={(checked) =>
updateScript("privileged", checked)
}
/>
<label>Privileged</label>
</div>
</div>
<Input
placeholder="Interface Port"
type="number"
@ -317,6 +406,12 @@ export default function JSONGenerator() {
)
}
/>
<div className="flex gap-2">
<Input
placeholder="Website URL"
value={script.website || ""}
onChange={(e) => updateScript("website", e.target.value || null)}
/>
<Input
placeholder="Documentation URL"
value={script.documentation || ""}
@ -324,21 +419,7 @@ export default function JSONGenerator() {
updateScript("documentation", e.target.value || null)
}
/>
<Input
placeholder="Website URL"
value={script.website || ""}
onChange={(e) => updateScript("website", e.target.value || null)}
/>
<Input
placeholder="Logo URL"
value={script.logo || ""}
onChange={(e) => updateScript("logo", e.target.value || null)}
/>
<Textarea
placeholder="Description"
value={script.description}
onChange={(e) => updateScript("description", e.target.value)}
/>
</div>
<h3 className="text-xl font-semibold">Install Methods</h3>
{script.install_methods.map((method, index) => (
<div key={index} className="space-y-2 border p-4 rounded">
@ -356,15 +437,9 @@ export default function JSONGenerator() {
<SelectItem value="alpine">Alpine</SelectItem>
</SelectContent>
</Select>
<Textarea
placeholder="Script"
value={method.script}
onChange={(e) =>
updateInstallMethod(index, "script", e.target.value)
}
/>
<div className="flex gap-2">
<Input
placeholder="CPU"
placeholder="CPU in Cores"
type="number"
value={method.resources.cpu || ""}
onChange={(e) =>
@ -375,7 +450,7 @@ export default function JSONGenerator() {
}
/>
<Input
placeholder="RAM"
placeholder="RAM in MB"
type="number"
value={method.resources.ram || ""}
onChange={(e) =>
@ -386,7 +461,7 @@ export default function JSONGenerator() {
}
/>
<Input
placeholder="HDD"
placeholder="HDD in GB"
type="number"
value={method.resources.hdd || ""}
onChange={(e) =>
@ -396,6 +471,8 @@ export default function JSONGenerator() {
})
}
/>
</div>
<div className="flex gap-2">
<Input
placeholder="OS"
value={method.resources.os || ""}
@ -417,6 +494,7 @@ export default function JSONGenerator() {
})
}
/>
</div>
<Button
variant="destructive"
onClick={() => removeInstallMethod(index)}
@ -472,8 +550,10 @@ export default function JSONGenerator() {
</Button>
</form>
</div>
<div className="w-1/2 p-4 bg-gray-100 overflow-y-auto">
<Alert className={isValid ? "bg-green-100" : "bg-red-100"}>
<div className="w-1/2 p-4 bg-background overflow-y-auto">
<Alert
className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}
>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
@ -481,7 +561,24 @@ export default function JSONGenerator() {
: "The current JSON does not match the required schema."}
</AlertDescription>
</Alert>
<pre className="mt-4 p-4 bg-white rounded shadow">
<pre className="mt-4 p-4 relative bg-secondary rounded shadow">
<Button
className="absolute right-2 top-2"
size="icon"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
toast.success("Copied metadata to clipboard");
}}
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Clipboard className="h-4 w-4" />
)}
</Button>
{JSON.stringify(script, null, 2)}
</pre>
</div>

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,387 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { Check, X as RemoveIcon } from "lucide-react";
import React, {
KeyboardEvent,
createContext,
forwardRef,
useCallback,
useContext,
useState,
} from "react";
interface MultiSelectorProps
extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
values: string[];
onValuesChange: (value: string[]) => void;
loop?: boolean;
}
interface MultiSelectContextProps {
value: string[];
onValueChange: (value: any) => void;
open: boolean;
setOpen: (value: boolean) => void;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
ref: React.RefObject<HTMLInputElement>;
handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
}
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null);
const useMultiSelect = () => {
const context = useContext(MultiSelectContext);
if (!context) {
throw new Error("useMultiSelect must be used within MultiSelectProvider");
}
return context;
};
/**
* MultiSelect Docs: {@link: https://shadcn-extension.vercel.app/docs/multi-select}
*/
// TODO : expose the visibility of the popup
const MultiSelector = ({
values: value,
onValuesChange: onValueChange,
loop = false,
className,
children,
dir,
...props
}: MultiSelectorProps) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState<boolean>(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const [isValueSelected, setIsValueSelected] = React.useState(false);
const [selectedValue, setSelectedValue] = React.useState("");
const onValueChangeHandler = useCallback(
(val: string) => {
if (value.includes(val)) {
onValueChange(value.filter((item) => item !== val));
} else {
onValueChange([...value, val]);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value],
);
const handleSelect = React.useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
const target = e.currentTarget;
const selection = target.value.substring(
target.selectionStart ?? 0,
target.selectionEnd ?? 0,
);
setSelectedValue(selection);
setIsValueSelected(selection === inputValue);
},
[inputValue],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const target = inputRef.current;
if (!target) return;
const moveNext = () => {
const nextIndex = activeIndex + 1;
setActiveIndex(
nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex,
);
};
const movePrev = () => {
const prevIndex = activeIndex - 1;
setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex);
};
const moveCurrent = () => {
const newIndex =
activeIndex - 1 <= 0
? value.length - 1 === 0
? -1
: 0
: activeIndex - 1;
setActiveIndex(newIndex);
};
switch (e.key) {
case "ArrowLeft":
if (dir === "rtl") {
if (value.length > 0 && (activeIndex !== -1 || loop)) {
moveNext();
}
} else {
if (value.length > 0 && target.selectionStart === 0) {
movePrev();
}
}
break;
case "ArrowRight":
if (dir === "rtl") {
if (value.length > 0 && target.selectionStart === 0) {
movePrev();
}
} else {
if (value.length > 0 && (activeIndex !== -1 || loop)) {
moveNext();
}
}
break;
case "Backspace":
case "Delete":
if (value.length > 0) {
if (activeIndex !== -1 && activeIndex < value.length) {
onValueChangeHandler(value[activeIndex]);
moveCurrent();
} else {
if (target.selectionStart === 0) {
if (selectedValue === inputValue || isValueSelected) {
onValueChangeHandler(value[value.length - 1]);
}
}
}
}
break;
case "Enter":
setOpen(true);
break;
case "Escape":
if (activeIndex !== -1) {
setActiveIndex(-1);
} else if (open) {
setOpen(false);
}
break;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value, inputValue, activeIndex, loop],
);
return (
<MultiSelectContext.Provider
value={{
value,
onValueChange: onValueChangeHandler,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
ref: inputRef,
handleSelect,
}}
>
<Command
onKeyDown={handleKeyDown}
className={cn(
"overflow-visible bg-transparent flex flex-col space-y-2",
className,
)}
dir={dir}
{...props}
>
{children}
</Command>
</MultiSelectContext.Provider>
);
};
const MultiSelectorTrigger = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { value, onValueChange, activeIndex } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<div
ref={ref}
className={cn(
"flex flex-wrap gap-1 p-1 py-2 ring-1 ring-muted rounded-lg bg-background",
{
"ring-1 focus-within:ring-ring": activeIndex === -1,
},
className,
)}
{...props}
>
{value.map((item, index) => (
<Badge
key={item}
className={cn(
"px-1 rounded-xl flex items-center gap-1",
activeIndex === index && "ring-2 ring-muted-foreground ",
)}
variant={"secondary"}
>
<span className="text-xs">{item}</span>
<button
aria-label={`Remove ${item} option`}
aria-roledescription="button to remove option"
type="button"
onMouseDown={mousePreventDefault}
onClick={() => onValueChange(item)}
>
<span className="sr-only">Remove {item} option</span>
<RemoveIcon className="h-4 w-4 hover:stroke-destructive" />
</button>
</Badge>
))}
{children}
</div>
);
});
MultiSelectorTrigger.displayName = "MultiSelectorTrigger";
const MultiSelectorInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => {
const {
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
handleSelect,
ref: inputRef,
} = useMultiSelect();
return (
<CommandPrimitive.Input
{...props}
tabIndex={0}
ref={inputRef}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onSelect={handleSelect}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
onClick={() => setActiveIndex(-1)}
className={cn(
"ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1",
className,
activeIndex !== -1 && "caret-transparent",
)}
/>
);
});
MultiSelectorInput.displayName = "MultiSelectorInput";
const MultiSelectorContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children }, ref) => {
const { open } = useMultiSelect();
return (
<div ref={ref} className="relative">
{open && children}
</div>
);
});
MultiSelectorContent.displayName = "MultiSelectorContent";
const MultiSelectorList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, children }, ref) => {
return (
<CommandList
ref={ref}
className={cn(
"p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-background shadow-md z-10 border border-muted top-0",
className,
)}
>
{children}
<CommandEmpty>
<span className="text-muted-foreground">No results found</span>
</CommandEmpty>
</CommandList>
);
});
MultiSelectorList.displayName = "MultiSelectorList";
const MultiSelectorItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
{ value: string } & React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Item
>
>(({ className, value, children, ...props }, ref) => {
const { value: Options, onValueChange, setInputValue } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const isIncluded = Options.includes(value);
return (
<CommandItem
ref={ref}
{...props}
onSelect={() => {
onValueChange(value);
setInputValue("");
}}
className={cn(
"rounded-md cursor-pointer px-2 py-1 transition-colors flex justify-between ",
className,
isIncluded && "opacity-50 cursor-default",
props.disabled && "opacity-50 cursor-not-allowed",
)}
onMouseDown={mousePreventDefault}
>
{children}
{isIncluded && <Check className="h-4 w-4" />}
</CommandItem>
);
});
MultiSelectorItem.displayName = "MultiSelectorItem";
export {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
};

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }