From 9c5c284a42479359db919e9e59be50d09cc9e5b4 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Mon, 10 Nov 2025 22:56:34 +0100 Subject: [PATCH] Tag options --- package-lock.json | 255 +++++ package.json | 3 + src/app/workspace/page.tsx | 21 +- src/components/ui/badge.tsx | 46 + src/components/ui/command.tsx | 184 ++++ src/components/ui/dialog.tsx | 143 +++ src/components/ui/multi-select.tsx | 1228 +++++++++++++++++++++++ src/components/ui/popover.tsx | 48 + src/components/ui/separator.tsx | 28 + src/components/view/board/BoardView.tsx | 4 + src/components/view/board/Task.tsx | 34 +- src/lib/types.ts | 2 +- 12 files changed, 1981 insertions(+), 15 deletions(-) create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/multi-select.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/separator.tsx diff --git a/package-lock.json b/package-lock.json index 8ae577a..0722688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,12 @@ "@hello-pangea/dnd": "^18.0.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.553.0", "next": "16.0.1", "react": "19.2.0", @@ -62,6 +65,44 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@hello-pangea/dnd": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", @@ -735,6 +776,29 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "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-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -950,6 +1014,93 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "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-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "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-portal": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", @@ -1039,6 +1190,52 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1142,6 +1339,48 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1517,6 +1756,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", diff --git a/package.json b/package.json index b116e8c..7340740 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,12 @@ "@hello-pangea/dnd": "^18.0.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.553.0", "next": "16.0.1", "react": "19.2.0", diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 9f86ac2..e36a105 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -16,15 +16,9 @@ export default function Workspace() { { id: id(), title: "Plan sprint", status: "todo" }, { id: id(), - title: "Build header", + title: "Build App", description: "A simple description", - tags: [ - { name: "Website", color: "pink" }, - { name: "Development", color: "blue" }, - { name: "API", color: "green" }, - { name: "App", color: "purple" }, - { name: "Rust", color: "orange" }, - ], + tags: ["website", "api", "app"], status: "inprogress", }, ]); @@ -40,6 +34,15 @@ export default function Workspace() { }; return ( - + ); } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx new file mode 100644 index 0000000..0276d05 --- /dev/null +++ b/src/components/ui/multi-select.tsx @@ -0,0 +1,1228 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Animation types and configurations + */ +export interface AnimationConfig { + /** Badge animation type */ + badgeAnimation?: "bounce" | "pulse" | "wiggle" | "fade" | "slide" | "none"; + /** Popover animation type */ + popoverAnimation?: "scale" | "slide" | "fade" | "flip" | "none"; + /** Option hover animation type */ + optionHoverAnimation?: "highlight" | "scale" | "glow" | "none"; + /** Animation duration in seconds */ + duration?: number; + /** Animation delay in seconds */ + delay?: number; +} + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva("m-1 transition-all duration-300 ease-in-out", { + variants: { + variant: { + default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + badgeAnimation: { + bounce: "hover:-translate-y-1 hover:scale-110", + pulse: "hover:animate-pulse", + wiggle: "hover:animate-wiggle", + fade: "hover:opacity-80", + slide: "hover:translate-x-1", + none: "", + }, + }, + defaultVariants: { + variant: "default", + badgeAnimation: "bounce", + }, +}); + +/** + * Option interface for MultiSelect component + */ +interface MultiSelectOption { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + /** Whether this option is disabled */ + disabled?: boolean; + /** Custom styling for the option */ + style?: { + /** Custom badge color */ + badgeColor?: string; + /** Custom icon color */ + iconColor?: string; + /** Gradient background for badge */ + gradient?: string; + }; +} + +/** + * Group interface for organizing options + */ +interface MultiSelectGroup { + /** Group heading */ + heading: string; + /** Options in this group */ + options: MultiSelectOption[]; +} + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends Omit< + React.ButtonHTMLAttributes, + "animationConfig" + >, + VariantProps { + /** + * An array of option objects or groups to be displayed in the multi-select component. + */ + options: MultiSelectOption[] | MultiSelectGroup[]; + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Advanced animation configuration for different component parts. + * Optional, allows fine-tuning of various animation effects. + */ + animationConfig?: AnimationConfig; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; + + /** + * If true, disables the select all functionality. + * Optional, defaults to false. + */ + hideSelectAll?: boolean; + + /** + * If true, shows search functionality in the popover. + * If false, hides the search input completely. + * Optional, defaults to true. + */ + searchable?: boolean; + + /** + * Custom empty state message when no options match search. + * Optional, defaults to "No results found." + */ + emptyIndicator?: React.ReactNode; + + /** + * If true, allows the component to grow and shrink with its content. + * If false, uses fixed width behavior. + * Optional, defaults to false. + */ + autoSize?: boolean; + + /** + * If true, shows badges in a single line with horizontal scroll. + * If false, badges wrap to multiple lines. + * Optional, defaults to false. + */ + singleLine?: boolean; + + /** + * Custom CSS class for the popover content. + * Optional, can be used to customize popover appearance. + */ + popoverClassName?: string; + + /** + * If true, disables the component completely. + * Optional, defaults to false. + */ + disabled?: boolean; + + /** + * Responsive configuration for different screen sizes. + * Allows customizing maxCount and other properties based on viewport. + * Can be boolean true for default responsive behavior or an object for custom configuration. + */ + responsive?: + | boolean + | { + /** Configuration for mobile devices (< 640px) */ + mobile?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for tablet devices (640px - 1024px) */ + tablet?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + /** Configuration for desktop devices (> 1024px) */ + desktop?: { + maxCount?: number; + hideIcons?: boolean; + compactMode?: boolean; + }; + }; + + /** + * Minimum width for the component. + * Optional, defaults to auto-sizing based on content. + * When set, component will not shrink below this width. + */ + minWidth?: string; + + /** + * Maximum width for the component. + * Optional, defaults to 100% of container. + * Component will not exceed container boundaries. + */ + maxWidth?: string; + + /** + * If true, automatically removes duplicate options based on their value. + * Optional, defaults to false (shows warning in dev mode instead). + */ + deduplicateOptions?: boolean; + + /** + * If true, the component will reset its internal state when defaultValue changes. + * Useful for React Hook Form integration and form reset functionality. + * Optional, defaults to true. + */ + resetOnDefaultValueChange?: boolean; + + /** + * If true, automatically closes the popover after selecting an option. + * Useful for single-selection-like behavior or mobile UX. + * Optional, defaults to false. + */ + closeOnSelect?: boolean; +} + +/** + * Imperative methods exposed through ref + */ +export interface MultiSelectRef { + /** + * Programmatically reset the component to its default value + */ + reset: () => void; + /** + * Get current selected values + */ + getSelectedValues: () => string[]; + /** + * Set selected values programmatically + */ + setSelectedValues: (values: string[]) => void; + /** + * Clear all selected values + */ + clear: () => void; + /** + * Focus the component + */ + focus: () => void; +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + animation = 0, + animationConfig, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + hideSelectAll = false, + searchable = true, + emptyIndicator, + autoSize = false, + singleLine = false, + popoverClassName, + disabled = false, + responsive, + minWidth, + maxWidth, + deduplicateOptions = false, + resetOnDefaultValueChange = true, + closeOnSelect = false, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + const [searchValue, setSearchValue] = React.useState(""); + + const [politeMessage, setPoliteMessage] = React.useState(""); + const [assertiveMessage, setAssertiveMessage] = React.useState(""); + const prevSelectedCount = React.useRef(selectedValues.length); + const prevIsOpen = React.useRef(isPopoverOpen); + const prevSearchValue = React.useRef(searchValue); + + const announce = React.useCallback( + (message: string, priority: "polite" | "assertive" = "polite") => { + if (priority === "assertive") { + setAssertiveMessage(message); + setTimeout(() => setAssertiveMessage(""), 100); + } else { + setPoliteMessage(message); + setTimeout(() => setPoliteMessage(""), 100); + } + }, + [] + ); + + const multiSelectId = React.useId(); + const listboxId = `${multiSelectId}-listbox`; + const triggerDescriptionId = `${multiSelectId}-description`; + const selectedCountId = `${multiSelectId}-count`; + + const prevDefaultValueRef = React.useRef(defaultValue); + + const isGroupedOptions = React.useCallback( + ( + opts: MultiSelectOption[] | MultiSelectGroup[] + ): opts is MultiSelectGroup[] => { + return opts.length > 0 && "heading" in opts[0]; + }, + [] + ); + + const arraysEqual = React.useCallback( + (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); + }, + [] + ); + + const resetToDefault = React.useCallback(() => { + setSelectedValues(defaultValue); + setIsPopoverOpen(false); + setSearchValue(""); + onValueChange(defaultValue); + }, [defaultValue, onValueChange]); + + const buttonRef = React.useRef(null); + + React.useImperativeHandle( + ref, + () => ({ + reset: resetToDefault, + getSelectedValues: () => selectedValues, + setSelectedValues: (values: string[]) => { + setSelectedValues(values); + onValueChange(values); + }, + clear: () => { + setSelectedValues([]); + onValueChange([]); + }, + focus: () => { + if (buttonRef.current) { + buttonRef.current.focus(); + const originalOutline = buttonRef.current.style.outline; + const originalOutlineOffset = buttonRef.current.style.outlineOffset; + buttonRef.current.style.outline = "2px solid hsl(var(--ring))"; + buttonRef.current.style.outlineOffset = "2px"; + setTimeout(() => { + if (buttonRef.current) { + buttonRef.current.style.outline = originalOutline; + buttonRef.current.style.outlineOffset = originalOutlineOffset; + } + }, 1000); + } + }, + }), + [resetToDefault, selectedValues, onValueChange] + ); + + const [screenSize, setScreenSize] = React.useState< + "mobile" | "tablet" | "desktop" + >("desktop"); + + React.useEffect(() => { + if (typeof window === "undefined") return; + const handleResize = () => { + const width = window.innerWidth; + if (width < 640) { + setScreenSize("mobile"); + } else if (width < 1024) { + setScreenSize("tablet"); + } else { + setScreenSize("desktop"); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + if (typeof window !== "undefined") { + window.removeEventListener("resize", handleResize); + } + }; + }, []); + + const getResponsiveSettings = () => { + if (!responsive) { + return { + maxCount: maxCount, + hideIcons: false, + compactMode: false, + }; + } + if (responsive === true) { + const defaultResponsive = { + mobile: { maxCount: 2, hideIcons: false, compactMode: true }, + tablet: { maxCount: 4, hideIcons: false, compactMode: false }, + desktop: { maxCount: 6, hideIcons: false, compactMode: false }, + }; + const currentSettings = defaultResponsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + } + const currentSettings = responsive[screenSize]; + return { + maxCount: currentSettings?.maxCount ?? maxCount, + hideIcons: currentSettings?.hideIcons ?? false, + compactMode: currentSettings?.compactMode ?? false, + }; + }; + + const responsiveSettings = getResponsiveSettings(); + + const getBadgeAnimationClass = () => { + if (animationConfig?.badgeAnimation) { + switch (animationConfig.badgeAnimation) { + case "bounce": + return isAnimating + ? "animate-bounce" + : "hover:-translate-y-1 hover:scale-110"; + case "pulse": + return "hover:animate-pulse"; + case "wiggle": + return "hover:animate-wiggle"; + case "fade": + return "hover:opacity-80"; + case "slide": + return "hover:translate-x-1"; + case "none": + return ""; + default: + return ""; + } + } + return isAnimating ? "animate-bounce" : ""; + }; + + const getPopoverAnimationClass = () => { + if (animationConfig?.popoverAnimation) { + switch (animationConfig.popoverAnimation) { + case "scale": + return "animate-scaleIn"; + case "slide": + return "animate-slideInDown"; + case "fade": + return "animate-fadeIn"; + case "flip": + return "animate-flipIn"; + case "none": + return ""; + default: + return ""; + } + } + return ""; + }; + + const getAllOptions = React.useCallback((): MultiSelectOption[] => { + if (options.length === 0) return []; + let allOptions: MultiSelectOption[]; + if (isGroupedOptions(options)) { + allOptions = options.flatMap((group) => group.options); + } else { + allOptions = options; + } + const valueSet = new Set(); + const duplicates: string[] = []; + const uniqueOptions: MultiSelectOption[] = []; + allOptions.forEach((option) => { + if (valueSet.has(option.value)) { + duplicates.push(option.value); + if (!deduplicateOptions) { + uniqueOptions.push(option); + } + } else { + valueSet.add(option.value); + uniqueOptions.push(option); + } + }); + if (process.env.NODE_ENV === "development" && duplicates.length > 0) { + const action = deduplicateOptions + ? "automatically removed" + : "detected"; + console.warn( + `MultiSelect: Duplicate option values ${action}: ${duplicates.join( + ", " + )}. ` + + `${ + deduplicateOptions + ? "Duplicates have been removed automatically." + : "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique." + }` + ); + } + return deduplicateOptions ? uniqueOptions : allOptions; + }, [options, deduplicateOptions, isGroupedOptions]); + + const getOptionByValue = React.useCallback( + (value: string): MultiSelectOption | undefined => { + const option = getAllOptions().find((option) => option.value === value); + if (!option && process.env.NODE_ENV === "development") { + console.warn( + `MultiSelect: Option with value "${value}" not found in options list` + ); + } + return option; + }, + [getAllOptions] + ); + + const filteredOptions = React.useMemo(() => { + if (!searchable || !searchValue) return options; + if (options.length === 0) return []; + if (isGroupedOptions(options)) { + return options + .map((group) => ({ + ...group, + options: group.options.filter( + (option) => + option.label + .toLowerCase() + .includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ), + })) + .filter((group) => group.options.length > 0); + } + return options.filter( + (option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) || + option.value.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [options, searchValue, searchable, isGroupedOptions]); + + const handleInputKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (optionValue: string) => { + if (disabled) return; + const option = getOptionByValue(optionValue); + if (option?.disabled) return; + const newSelectedValues = selectedValues.includes(optionValue) + ? selectedValues.filter((value) => value !== optionValue) + : [...selectedValues, optionValue]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + const handleClear = () => { + if (disabled) return; + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + if (disabled) return; + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + if (disabled) return; + const newSelectedValues = selectedValues.slice( + 0, + responsiveSettings.maxCount + ); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (disabled) return; + const allOptions = getAllOptions().filter((option) => !option.disabled); + if (selectedValues.length === allOptions.length) { + handleClear(); + } else { + const allValues = allOptions.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + + if (closeOnSelect) { + setIsPopoverOpen(false); + } + }; + + React.useEffect(() => { + if (!resetOnDefaultValueChange) return; + const prevDefaultValue = prevDefaultValueRef.current; + if (!arraysEqual(prevDefaultValue, defaultValue)) { + if (!arraysEqual(selectedValues, defaultValue)) { + setSelectedValues(defaultValue); + } + prevDefaultValueRef.current = [...defaultValue]; + } + }, [defaultValue, selectedValues, arraysEqual, resetOnDefaultValueChange]); + + const getWidthConstraints = () => { + const defaultMinWidth = screenSize === "mobile" ? "0px" : "200px"; + const effectiveMinWidth = minWidth || defaultMinWidth; + const effectiveMaxWidth = maxWidth || "100%"; + return { + minWidth: effectiveMinWidth, + maxWidth: effectiveMaxWidth, + width: autoSize ? "auto" : "100%", + }; + }; + + const widthConstraints = getWidthConstraints(); + + React.useEffect(() => { + if (!isPopoverOpen) { + setSearchValue(""); + } + }, [isPopoverOpen]); + + React.useEffect(() => { + const selectedCount = selectedValues.length; + const allOptions = getAllOptions(); + const totalOptions = allOptions.filter((opt) => !opt.disabled).length; + if (selectedCount !== prevSelectedCount.current) { + const diff = selectedCount - prevSelectedCount.current; + if (diff > 0) { + const addedItems = selectedValues.slice(-diff); + const addedLabels = addedItems + .map( + (value) => allOptions.find((opt) => opt.value === value)?.label + ) + .filter(Boolean); + + if (addedLabels.length === 1) { + announce( + `${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.` + ); + } else { + announce( + `${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.` + ); + } + } else if (diff < 0) { + announce( + `Option removed. ${selectedCount} of ${totalOptions} options selected.` + ); + } + prevSelectedCount.current = selectedCount; + } + + if (isPopoverOpen !== prevIsOpen.current) { + if (isPopoverOpen) { + announce( + `Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.` + ); + } else { + announce("Dropdown closed."); + } + prevIsOpen.current = isPopoverOpen; + } + + if ( + searchValue !== prevSearchValue.current && + searchValue !== undefined + ) { + if (searchValue && isPopoverOpen) { + const filteredCount = allOptions.filter( + (opt) => + opt.label.toLowerCase().includes(searchValue.toLowerCase()) || + opt.value.toLowerCase().includes(searchValue.toLowerCase()) + ).length; + + announce( + `${filteredCount} option${ + filteredCount === 1 ? "" : "s" + } found for "${searchValue}"` + ); + } + prevSearchValue.current = searchValue; + } + }, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions]); + + return ( + <> +
+
+ {politeMessage} +
+
+ {assertiveMessage} +
+
+ + +
+ Multi-select dropdown. Use arrow keys to navigate, Enter to select, + and Escape to close. +
+
+ {selectedValues.length === 0 + ? "No options selected" + : `${selectedValues.length} option${ + selectedValues.length === 1 ? "" : "s" + } selected: ${selectedValues + .map((value) => getOptionByValue(value)?.label) + .filter(Boolean) + .join(", ")}`} +
+ + + + + setIsPopoverOpen(false)} + > + + {searchable && ( + + )} + {searchable && ( +
+ Type to filter options. Use arrow keys to navigate results. +
+ )} + + + {emptyIndicator || "No results found."} + {" "} + {!hideSelectAll && !searchValue && ( + + !opt.disabled).length + } + aria-label={`Select all ${ + getAllOptions().length + } options`} + className="cursor-pointer" + > +
!opt.disabled) + .length + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + aria-hidden="true" + > + +
+ + (Select All + {getAllOptions().length > 20 + ? ` - ${getAllOptions().length} options` + : ""} + ) + +
+
+ )} + {isGroupedOptions(filteredOptions) ? ( + filteredOptions.map((group) => ( + + {group.options.map((option) => { + const isSelected = selectedValues.includes( + option.value + ); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled} + > + + {option.icon && ( + + ); + })} + + )) + ) : ( + + {filteredOptions.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + role="option" + aria-selected={isSelected} + aria-disabled={option.disabled} + aria-label={`${option.label}${ + isSelected ? ", selected" : ", not selected" + }${option.disabled ? ", disabled" : ""}`} + className={cn( + "cursor-pointer", + option.disabled && "opacity-50 cursor-not-allowed" + )} + disabled={option.disabled} + > + + {option.icon && ( + + ); + })} + + )} + + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Close + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ + ); + } +); + +MultiSelect.displayName = "MultiSelect"; +export type { MultiSelectOption, MultiSelectGroup, MultiSelectProps }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..01e468b --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..275381c --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 067c962..14950ee 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -6,6 +6,7 @@ import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; import { PlusIcon } from "lucide-react"; import { SetState, Task } from "@/lib/types"; import BoardTask from "./Task"; +import { Color } from "@/lib/ui/color"; const columnLabels: Record = { todo: "To do", @@ -14,10 +15,12 @@ const columnLabels: Record = { }; export default function BoardView({ + tags, tasks, setTasks, createTask, }: { + tags: Record; tasks: Task[]; setTasks: SetState; createTask: (status: string) => void; @@ -93,6 +96,7 @@ export default function BoardView({ } provided={provided} task={task} + tags={tags} /> )} diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 00ce7a3..69618fc 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -7,6 +7,7 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { MultiSelect } from "@/components/ui/multi-select"; import { Sheet, SheetContent, @@ -16,17 +17,19 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { Task } from "@/lib/types"; -import { getColor } from "@/lib/ui/color"; +import { Color, getColor } from "@/lib/ui/color"; import { DraggableProvided } from "@hello-pangea/dnd"; export default function BoardTask({ task, provided, setTask, + tags, }: { task: Task; setTask: (t: Task) => void; provided: DraggableProvided; + tags: Record; }) { return ( @@ -44,11 +47,11 @@ export default function BoardTask({ {task.tags?.map((tag, i) => ( - {tag.name} + {tags[tag].name} ))} @@ -56,7 +59,7 @@ export default function BoardTask({ - Edit profile + Edit task Make changes to the task here. Click save when you're done. @@ -78,6 +81,27 @@ export default function BoardTask({ } />
+
+ + ({ + label: tag.name, + value: id, + style: { + iconColor: getColor(tag.color).color, + badgeColor: getColor(tag.color).backgroundColor, + }, + }))} + onValueChange={(tags) => setTask({ ...task, tags })} + defaultValue={task.tags} + /> + {/* + setTask({ ...task, description: e.target.value }) + } + /> */} +
diff --git a/src/lib/types.ts b/src/lib/types.ts index b4f6b5e..6d7c086 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,7 +5,7 @@ export type Task = { title: string; status: string; description?: string; - tags?: { name: string; color: Color }[]; + tags?: string[]; }; export type SetState = React.Dispatch>;