├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── components.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── icons.tsx │ ├── mode-toggle.tsx │ ├── multi-select.tsx │ ├── page-header.tsx │ ├── site-header.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── separator.tsx │ │ ├── sonner.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts └── lib │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 sersavan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Star History 2 | 3 | [![Star History Chart](https://api.star-history.com/svg?repos=sersavan/shadcn-multi-select-component&type=Date)](https://star-history.com/#sersavan/shadcn-multi-select-component&Date) 4 | 5 | ## Multi-Select Component Setup in Next.js 6 | 7 | ### Prerequisites 8 | 9 | Ensure you have a Next.js project set up. If not, create one: 10 | 11 | ```bash 12 | npx create-next-app my-app --typescript 13 | cd my-app 14 | ``` 15 | 16 | ### Step 1: Install shadcn Components 17 | 18 | Install required shadcn components: 19 | 20 | ```bash 21 | npx shadcn@latest init 22 | npx shadcn@latest add command popover button separator badge 23 | ``` 24 | 25 | ### Step 2: Create the Multi-Select Component 26 | 27 | Create `multi-select.tsx` in your `components` directory: 28 | 29 | ```tsx 30 | // src/components/multi-select.tsx 31 | 32 | import * as React from "react"; 33 | import { cva, type VariantProps } from "class-variance-authority"; 34 | import { 35 | CheckIcon, 36 | XCircle, 37 | ChevronDown, 38 | XIcon, 39 | WandSparkles, 40 | } from "lucide-react"; 41 | 42 | import { cn } from "@/lib/utils"; 43 | import { Separator } from "@/components/ui/separator"; 44 | import { Button } from "@/components/ui/button"; 45 | import { Badge } from "@/components/ui/badge"; 46 | import { 47 | Popover, 48 | PopoverContent, 49 | PopoverTrigger, 50 | } from "@/components/ui/popover"; 51 | import { 52 | Command, 53 | CommandEmpty, 54 | CommandGroup, 55 | CommandInput, 56 | CommandItem, 57 | CommandList, 58 | CommandSeparator, 59 | } from "@/components/ui/command"; 60 | 61 | /** 62 | * Variants for the multi-select component to handle different styles. 63 | * Uses class-variance-authority (cva) to define different styles based on "variant" prop. 64 | */ 65 | const multiSelectVariants = cva( 66 | "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", 67 | { 68 | variants: { 69 | variant: { 70 | default: 71 | "border-foreground/10 text-foreground bg-card hover:bg-card/80", 72 | secondary: 73 | "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", 74 | destructive: 75 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 76 | inverted: "inverted", 77 | }, 78 | }, 79 | defaultVariants: { 80 | variant: "default", 81 | }, 82 | } 83 | ); 84 | 85 | /** 86 | * Props for MultiSelect component 87 | */ 88 | interface MultiSelectProps 89 | extends React.ButtonHTMLAttributes, 90 | VariantProps { 91 | /** 92 | * An array of option objects to be displayed in the multi-select component. 93 | * Each option object has a label, value, and an optional icon. 94 | */ 95 | options: { 96 | /** The text to display for the option. */ 97 | label: string; 98 | /** The unique value associated with the option. */ 99 | value: string; 100 | /** Optional icon component to display alongside the option. */ 101 | icon?: React.ComponentType<{ className?: string }>; 102 | }[]; 103 | 104 | /** 105 | * Callback function triggered when the selected values change. 106 | * Receives an array of the new selected values. 107 | */ 108 | onValueChange: (value: string[]) => void; 109 | 110 | /** The default selected values when the component mounts. */ 111 | defaultValue?: string[]; 112 | 113 | /** 114 | * Placeholder text to be displayed when no values are selected. 115 | * Optional, defaults to "Select options". 116 | */ 117 | placeholder?: string; 118 | 119 | /** 120 | * Animation duration in seconds for the visual effects (e.g., bouncing badges). 121 | * Optional, defaults to 0 (no animation). 122 | */ 123 | animation?: number; 124 | 125 | /** 126 | * Maximum number of items to display. Extra selected items will be summarized. 127 | * Optional, defaults to 3. 128 | */ 129 | maxCount?: number; 130 | 131 | /** 132 | * The modality of the popover. When set to true, interaction with outside elements 133 | * will be disabled and only popover content will be visible to screen readers. 134 | * Optional, defaults to false. 135 | */ 136 | modalPopover?: boolean; 137 | 138 | /** 139 | * If true, renders the multi-select component as a child of another component. 140 | * Optional, defaults to false. 141 | */ 142 | asChild?: boolean; 143 | 144 | /** 145 | * Additional class names to apply custom styles to the multi-select component. 146 | * Optional, can be used to add custom styles. 147 | */ 148 | className?: string; 149 | } 150 | 151 | export const MultiSelect = React.forwardRef< 152 | HTMLButtonElement, 153 | MultiSelectProps 154 | >( 155 | ( 156 | { 157 | options, 158 | onValueChange, 159 | variant, 160 | defaultValue = [], 161 | placeholder = "Select options", 162 | animation = 0, 163 | maxCount = 3, 164 | modalPopover = false, 165 | asChild = false, 166 | className, 167 | ...props 168 | }, 169 | ref 170 | ) => { 171 | const [selectedValues, setSelectedValues] = 172 | React.useState(defaultValue); 173 | const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); 174 | const [isAnimating, setIsAnimating] = React.useState(false); 175 | 176 | const handleInputKeyDown = ( 177 | event: React.KeyboardEvent 178 | ) => { 179 | if (event.key === "Enter") { 180 | setIsPopoverOpen(true); 181 | } else if (event.key === "Backspace" && !event.currentTarget.value) { 182 | const newSelectedValues = [...selectedValues]; 183 | newSelectedValues.pop(); 184 | setSelectedValues(newSelectedValues); 185 | onValueChange(newSelectedValues); 186 | } 187 | }; 188 | 189 | const toggleOption = (option: string) => { 190 | const newSelectedValues = selectedValues.includes(option) 191 | ? selectedValues.filter((value) => value !== option) 192 | : [...selectedValues, option]; 193 | setSelectedValues(newSelectedValues); 194 | onValueChange(newSelectedValues); 195 | }; 196 | 197 | const handleClear = () => { 198 | setSelectedValues([]); 199 | onValueChange([]); 200 | }; 201 | 202 | const handleTogglePopover = () => { 203 | setIsPopoverOpen((prev) => !prev); 204 | }; 205 | 206 | const clearExtraOptions = () => { 207 | const newSelectedValues = selectedValues.slice(0, maxCount); 208 | setSelectedValues(newSelectedValues); 209 | onValueChange(newSelectedValues); 210 | }; 211 | 212 | const toggleAll = () => { 213 | if (selectedValues.length === options.length) { 214 | handleClear(); 215 | } else { 216 | const allValues = options.map((option) => option.value); 217 | setSelectedValues(allValues); 218 | onValueChange(allValues); 219 | } 220 | }; 221 | 222 | return ( 223 | 228 | 229 | 311 | 312 | setIsPopoverOpen(false)} 316 | > 317 | 318 | 322 | 323 | No results found. 324 | 325 | 330 |
338 | 339 |
340 | (Select All) 341 |
342 | {options.map((option) => { 343 | const isSelected = selectedValues.includes(option.value); 344 | return ( 345 | toggleOption(option.value)} 348 | className="cursor-pointer" 349 | > 350 |
358 | 359 |
360 | {option.icon && ( 361 | 362 | )} 363 | {option.label} 364 |
365 | ); 366 | })} 367 |
368 | 369 | 370 |
371 | {selectedValues.length > 0 && ( 372 | <> 373 | 377 | Clear 378 | 379 | 383 | 384 | )} 385 | setIsPopoverOpen(false)} 387 | className="flex-1 justify-center cursor-pointer max-w-full" 388 | > 389 | Close 390 | 391 |
392 |
393 |
394 |
395 |
396 | {animation > 0 && selectedValues.length > 0 && ( 397 | setIsAnimating(!isAnimating)} 403 | /> 404 | )} 405 |
406 | ); 407 | } 408 | ); 409 | 410 | MultiSelect.displayName = "MultiSelect"; 411 | ``` 412 | 413 | ### Step 3: Integrate the Component 414 | 415 | Update `page.tsx`: 416 | 417 | ```tsx 418 | // src/app/page.tsx 419 | 420 | "use client"; 421 | 422 | import React, { useState } from "react"; 423 | import { MultiSelect } from "@/components/multi-select"; 424 | import { Cat, Dog, Fish, Rabbit, Turtle } from "lucide-react"; 425 | 426 | const frameworksList = [ 427 | { value: "react", label: "React", icon: Turtle }, 428 | { value: "angular", label: "Angular", icon: Cat }, 429 | { value: "vue", label: "Vue", icon: Dog }, 430 | { value: "svelte", label: "Svelte", icon: Rabbit }, 431 | { value: "ember", label: "Ember", icon: Fish }, 432 | ]; 433 | 434 | function Home() { 435 | const [selectedFrameworks, setSelectedFrameworks] = useState(["react", "angular"]); 436 | 437 | return ( 438 |
439 |

Multi-Select Component

440 | 449 |
450 |

Selected Frameworks:

451 |
    452 | {selectedFrameworks.map((framework) => ( 453 |
  • {framework}
  • 454 | ))} 455 |
456 |
457 |
458 | ); 459 | } 460 | 461 | export default Home; 462 | ``` 463 | 464 | ### Step 4: Run Your Project 465 | 466 | ```bash 467 | npm run dev 468 | ``` 469 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-select-component", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.3.4", 13 | "@radix-ui/react-dialog": "^1.0.5", 14 | "@radix-ui/react-label": "^2.0.2", 15 | "@radix-ui/react-popover": "^1.0.7", 16 | "@radix-ui/react-separator": "^1.0.3", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "@radix-ui/react-toast": "^1.1.5", 19 | "@vercel/analytics": "^1.2.2", 20 | "@vercel/speed-insights": "^1.0.10", 21 | "class-variance-authority": "^0.7.0", 22 | "clsx": "^2.1.0", 23 | "cmdk": "^1.0.0", 24 | "lucide-react": "^0.367.0", 25 | "next": "14.1.4", 26 | "next-themes": "^0.3.0", 27 | "react": "^18", 28 | "react-dom": "^18", 29 | "react-hook-form": "^7.51.2", 30 | "react-wrap-balancer": "^1.1.0", 31 | "sonner": "^1.4.41", 32 | "tailwind-merge": "^2.2.2", 33 | "tailwindcss-animate": "^1.0.7", 34 | "zod": "^3.22.4" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^20", 38 | "@types/react": "^18", 39 | "@types/react-dom": "^18", 40 | "autoprefixer": "^10.0.1", 41 | "eslint": "^8", 42 | "eslint-config-next": "14.1.4", 43 | "postcss": "^8", 44 | "tailwindcss": "^3.3.0", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sersavan/shadcn-multi-select-component/db485c0955016c7d60b6f8535b968522429d5d81/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { SpeedInsights } from "@vercel/speed-insights/next"; 4 | import { Analytics } from "@vercel/analytics/react"; 5 | 6 | import "./globals.css"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import { ThemeProvider } from "@/components/theme-provider"; 9 | import { SiteHeader } from "@/components/site-header"; 10 | import { cn } from "@/lib/utils"; 11 | 12 | const inter = Inter({ subsets: ["latin"] }); 13 | 14 | export const metadata: Metadata = { 15 | title: "shadcn Multi select component", 16 | description: "A multi select component designed with shadcn/ui", 17 | }; 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode; 23 | }>) { 24 | return ( 25 | 26 | 32 | 38 |
39 | 40 | {children} 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { z } from "zod"; 6 | import { toast } from "sonner"; 7 | import Link from "next/link"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | import { Button, buttonVariants } from "@/components/ui/button"; 11 | import { Card } from "@/components/ui/card"; 12 | import { Icons } from "@/components/icons"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormDescription, 17 | FormField, 18 | FormItem, 19 | FormLabel, 20 | FormMessage, 21 | } from "@/components/ui/form"; 22 | import { 23 | PageActions, 24 | PageHeader, 25 | PageHeaderDescription, 26 | PageHeaderHeading, 27 | } from "@/components/page-header"; 28 | import { MultiSelect } from "@/components/multi-select"; 29 | 30 | const frameworksList = [ 31 | { 32 | value: "next.js", 33 | label: "Next.js", 34 | icon: Icons.dog, 35 | }, 36 | { 37 | value: "sveltekit", 38 | label: "SvelteKit", 39 | icon: Icons.cat, 40 | }, 41 | { 42 | value: "nuxt.js", 43 | label: "Nuxt.js", 44 | icon: Icons.turtle, 45 | }, 46 | { 47 | value: "remix", 48 | label: "Remix", 49 | icon: Icons.rabbit, 50 | }, 51 | { 52 | value: "astro", 53 | label: "Astro", 54 | icon: Icons.fish, 55 | }, 56 | ]; 57 | 58 | const FormSchema = z.object({ 59 | frameworks: z 60 | .array(z.string().min(1)) 61 | .min(1) 62 | .nonempty("Please select at least one framework."), 63 | }); 64 | 65 | export default function Home() { 66 | const form = useForm>({ 67 | resolver: zodResolver(FormSchema), 68 | defaultValues: { 69 | frameworks: ["next.js", "nuxt.js"], 70 | }, 71 | }); 72 | 73 | function onSubmit(data: z.infer) { 74 | toast( 75 | `You have selected following frameworks: ${data.frameworks.join(", ")}.` 76 | ); 77 | } 78 | 79 | return ( 80 |
81 | 82 | Multi select component 83 | assembled with shadcn/ui 84 | 85 | 91 | 92 | GitHub 93 | 94 | 95 | 96 | 97 |
98 | 99 | ( 103 | 104 | Frameworks 105 | 106 | 115 | 116 | 117 | Choose the frameworks you are interested in. 118 | 119 | 120 | 121 | )} 122 | /> 123 | 126 | 127 | 128 |
129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MoonIcon, 3 | SunMedium, 4 | LucideProps, 5 | Cat, 6 | Dog, 7 | Fish, 8 | Rabbit, 9 | Turtle, 10 | } from "lucide-react"; 11 | 12 | export const Icons = { 13 | moonIcon: MoonIcon, 14 | sunIcon: SunMedium, 15 | cat: Cat, 16 | dog: Dog, 17 | fish: Fish, 18 | rabbit: Rabbit, 19 | turtle: Turtle, 20 | gitHub: ({ ...props }: LucideProps) => ( 21 | 36 | ), 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { Icons } from "@/components/icons"; 7 | 8 | export function ModeToggle() { 9 | const { setTheme, theme } = useTheme(); 10 | 11 | return ( 12 | 21 | ); 22 | } 23 | 24 | export default ModeToggle; 25 | -------------------------------------------------------------------------------- /src/components/multi-select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import { 4 | CheckIcon, 5 | XCircle, 6 | ChevronDown, 7 | XIcon, 8 | WandSparkles, 9 | } from "lucide-react"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | import { Separator } from "@/components/ui/separator"; 13 | import { Button } from "@/components/ui/button"; 14 | import { Badge } from "@/components/ui/badge"; 15 | import { 16 | Popover, 17 | PopoverContent, 18 | PopoverTrigger, 19 | } from "@/components/ui/popover"; 20 | import { 21 | Command, 22 | CommandEmpty, 23 | CommandGroup, 24 | CommandInput, 25 | CommandItem, 26 | CommandList, 27 | CommandSeparator, 28 | } from "@/components/ui/command"; 29 | 30 | /** 31 | * Variants for the multi-select component to handle different styles. 32 | * Uses class-variance-authority (cva) to define different styles based on "variant" prop. 33 | */ 34 | const multiSelectVariants = cva( 35 | "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", 36 | { 37 | variants: { 38 | variant: { 39 | default: 40 | "border-foreground/10 text-foreground bg-card hover:bg-card/80", 41 | secondary: 42 | "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", 43 | destructive: 44 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 45 | inverted: "inverted", 46 | }, 47 | }, 48 | defaultVariants: { 49 | variant: "default", 50 | }, 51 | } 52 | ); 53 | 54 | /** 55 | * Props for MultiSelect component 56 | */ 57 | interface MultiSelectProps 58 | extends React.ButtonHTMLAttributes, 59 | VariantProps { 60 | /** 61 | * An array of option objects to be displayed in the multi-select component. 62 | * Each option object has a label, value, and an optional icon. 63 | */ 64 | options: { 65 | /** The text to display for the option. */ 66 | label: string; 67 | /** The unique value associated with the option. */ 68 | value: string; 69 | /** Optional icon component to display alongside the option. */ 70 | icon?: React.ComponentType<{ className?: string }>; 71 | }[]; 72 | 73 | /** 74 | * Callback function triggered when the selected values change. 75 | * Receives an array of the new selected values. 76 | */ 77 | onValueChange: (value: string[]) => void; 78 | 79 | /** The default selected values when the component mounts. */ 80 | defaultValue?: string[]; 81 | 82 | /** 83 | * Placeholder text to be displayed when no values are selected. 84 | * Optional, defaults to "Select options". 85 | */ 86 | placeholder?: string; 87 | 88 | /** 89 | * Animation duration in seconds for the visual effects (e.g., bouncing badges). 90 | * Optional, defaults to 0 (no animation). 91 | */ 92 | animation?: number; 93 | 94 | /** 95 | * Maximum number of items to display. Extra selected items will be summarized. 96 | * Optional, defaults to 3. 97 | */ 98 | maxCount?: number; 99 | 100 | /** 101 | * The modality of the popover. When set to true, interaction with outside elements 102 | * will be disabled and only popover content will be visible to screen readers. 103 | * Optional, defaults to false. 104 | */ 105 | modalPopover?: boolean; 106 | 107 | /** 108 | * If true, renders the multi-select component as a child of another component. 109 | * Optional, defaults to false. 110 | */ 111 | asChild?: boolean; 112 | 113 | /** 114 | * Additional class names to apply custom styles to the multi-select component. 115 | * Optional, can be used to add custom styles. 116 | */ 117 | className?: string; 118 | } 119 | 120 | export const MultiSelect = React.forwardRef< 121 | HTMLButtonElement, 122 | MultiSelectProps 123 | >( 124 | ( 125 | { 126 | options, 127 | onValueChange, 128 | variant, 129 | defaultValue = [], 130 | placeholder = "Select options", 131 | animation = 0, 132 | maxCount = 3, 133 | modalPopover = false, 134 | asChild = false, 135 | className, 136 | ...props 137 | }, 138 | ref 139 | ) => { 140 | const [selectedValues, setSelectedValues] = 141 | React.useState(defaultValue); 142 | const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); 143 | const [isAnimating, setIsAnimating] = React.useState(false); 144 | 145 | const handleInputKeyDown = ( 146 | event: React.KeyboardEvent 147 | ) => { 148 | if (event.key === "Enter") { 149 | setIsPopoverOpen(true); 150 | } else if (event.key === "Backspace" && !event.currentTarget.value) { 151 | const newSelectedValues = [...selectedValues]; 152 | newSelectedValues.pop(); 153 | setSelectedValues(newSelectedValues); 154 | onValueChange(newSelectedValues); 155 | } 156 | }; 157 | 158 | const toggleOption = (option: string) => { 159 | const newSelectedValues = selectedValues.includes(option) 160 | ? selectedValues.filter((value) => value !== option) 161 | : [...selectedValues, option]; 162 | setSelectedValues(newSelectedValues); 163 | onValueChange(newSelectedValues); 164 | }; 165 | 166 | const handleClear = () => { 167 | setSelectedValues([]); 168 | onValueChange([]); 169 | }; 170 | 171 | const handleTogglePopover = () => { 172 | setIsPopoverOpen((prev) => !prev); 173 | }; 174 | 175 | const clearExtraOptions = () => { 176 | const newSelectedValues = selectedValues.slice(0, maxCount); 177 | setSelectedValues(newSelectedValues); 178 | onValueChange(newSelectedValues); 179 | }; 180 | 181 | const toggleAll = () => { 182 | if (selectedValues.length === options.length) { 183 | handleClear(); 184 | } else { 185 | const allValues = options.map((option) => option.value); 186 | setSelectedValues(allValues); 187 | onValueChange(allValues); 188 | } 189 | }; 190 | 191 | return ( 192 | 197 | 198 | 280 | 281 | setIsPopoverOpen(false)} 285 | > 286 | 287 | 291 | 292 | No results found. 293 | 294 | 299 |
307 | 308 |
309 | (Select All) 310 |
311 | {options.map((option) => { 312 | const isSelected = selectedValues.includes(option.value); 313 | return ( 314 | toggleOption(option.value)} 317 | className="cursor-pointer" 318 | > 319 |
327 | 328 |
329 | {option.icon && ( 330 | 331 | )} 332 | {option.label} 333 |
334 | ); 335 | })} 336 |
337 | 338 | 339 |
340 | {selectedValues.length > 0 && ( 341 | <> 342 | 346 | Clear 347 | 348 | 352 | 353 | )} 354 | setIsPopoverOpen(false)} 356 | className="flex-1 justify-center cursor-pointer max-w-full" 357 | > 358 | Close 359 | 360 |
361 |
362 |
363 |
364 |
365 | {animation > 0 && selectedValues.length > 0 && ( 366 | setIsAnimating(!isAnimating)} 372 | /> 373 | )} 374 |
375 | ); 376 | } 377 | ); 378 | 379 | MultiSelect.displayName = "MultiSelect"; 380 | -------------------------------------------------------------------------------- /src/components/page-header.tsx: -------------------------------------------------------------------------------- 1 | import Balance from "react-wrap-balancer"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function PageHeader({ 6 | className, 7 | children, 8 | ...props 9 | }: React.HTMLAttributes) { 10 | return ( 11 |
18 | {children} 19 |
20 | ); 21 | } 22 | 23 | function PageHeaderHeading({ 24 | className, 25 | ...props 26 | }: React.HTMLAttributes) { 27 | return ( 28 |

35 | ); 36 | } 37 | 38 | function PageHeaderDescription({ 39 | className, 40 | ...props 41 | }: React.HTMLAttributes) { 42 | return ( 43 | 50 | ); 51 | } 52 | 53 | function PageActions({ 54 | className, 55 | ...props 56 | }: React.HTMLAttributes) { 57 | return ( 58 |
65 | ); 66 | } 67 | 68 | export { PageHeader, PageHeaderHeading, PageHeaderDescription, PageActions }; 69 | -------------------------------------------------------------------------------- /src/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import ModeToggle from "@/components/mode-toggle"; 4 | import { buttonVariants } from "@/components/ui/button"; 5 | import { Icons } from "@/components/icons"; 6 | 7 | export async function SiteHeader() { 8 | return ( 9 |
10 |
11 |
12 | 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )) 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )) 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )) 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )) 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )) 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ) 142 | } 143 | CommandShortcut.displayName = "CommandShortcut" 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | } 156 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |