Last active
December 19, 2024 07:42
-
-
Save mattiapomelli/a363d4ffc308c9394b32386b61494a9b to your computer and use it in GitHub Desktop.
MultiSelect w shadcn/ui
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react" | |
import { cva, type VariantProps } from "class-variance-authority" | |
import { cn } from "@/lib/utils" | |
const badgeVariants = cva( | |
"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", | |
{ | |
variants: { | |
variant: { | |
default: | |
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80", | |
secondary: | |
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", | |
destructive: | |
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", | |
outline: "text-foreground", | |
}, | |
}, | |
defaultVariants: { | |
variant: "default", | |
}, | |
} | |
) | |
export interface BadgeProps | |
extends React.HTMLAttributes<HTMLDivElement>, | |
VariantProps<typeof badgeVariants> {} | |
function Badge({ className, variant, ...props }: BadgeProps) { | |
return ( | |
<div className={cn(badgeVariants({ variant }), className)} {...props} /> | |
) | |
} | |
export { Badge, badgeVariants } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import * as React from "react"; | |
import { type DialogProps } from "@radix-ui/react-dialog"; | |
import { Command as CommandPrimitive } from "cmdk"; | |
import { cn } from "@/lib/utils"; | |
import { Dialog, DialogContent } from "@/components/ui/dialog"; | |
const Command = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive> | |
>(({ className, ...props }, ref) => ( | |
<CommandPrimitive | |
ref={ref} | |
className={cn( | |
"flex size-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
Command.displayName = CommandPrimitive.displayName; | |
const CommandDialog = ({ children, ...props }: DialogProps) => { | |
return ( | |
<Dialog {...props}> | |
<DialogContent className="overflow-hidden p-0 shadow-lg"> | |
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"> | |
{children} | |
</Command> | |
</DialogContent> | |
</Dialog> | |
); | |
}; | |
const CommandInput = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.Input>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> | |
>(({ className, ...props }, ref) => ( | |
<div className="flex w-full items-center px-0" cmdk-input-wrapper=""> | |
<CommandPrimitive.Input | |
ref={ref} | |
className={cn( | |
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", | |
className, | |
)} | |
{...props} | |
/> | |
</div> | |
)); | |
CommandInput.displayName = CommandPrimitive.Input.displayName; | |
const CommandList = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.List>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> | |
>(({ className, ...props }, ref) => ( | |
<CommandPrimitive.List | |
ref={ref} | |
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} | |
{...props} | |
/> | |
)); | |
CommandList.displayName = CommandPrimitive.List.displayName; | |
const CommandEmpty = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.Empty>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> | |
>((props, ref) => ( | |
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} /> | |
)); | |
CommandEmpty.displayName = CommandPrimitive.Empty.displayName; | |
const CommandGroup = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.Group>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> | |
>(({ className, ...props }, ref) => ( | |
<CommandPrimitive.Group | |
ref={ref} | |
className={cn( | |
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
CommandGroup.displayName = CommandPrimitive.Group.displayName; | |
const CommandSeparator = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.Separator>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> | |
>(({ className, ...props }, ref) => ( | |
<CommandPrimitive.Separator | |
ref={ref} | |
className={cn("-mx-1 h-px bg-border", className)} | |
{...props} | |
/> | |
)); | |
CommandSeparator.displayName = CommandPrimitive.Separator.displayName; | |
const CommandItem = React.forwardRef< | |
React.ElementRef<typeof CommandPrimitive.Item>, | |
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> | |
>(({ className, ...props }, ref) => ( | |
<CommandPrimitive.Item | |
ref={ref} | |
className={cn( | |
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
CommandItem.displayName = CommandPrimitive.Item.displayName; | |
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { | |
return ( | |
<span | |
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} | |
{...props} | |
/> | |
); | |
}; | |
CommandShortcut.displayName = "CommandShortcut"; | |
export { | |
Command, | |
CommandDialog, | |
CommandInput, | |
CommandList, | |
CommandEmpty, | |
CommandGroup, | |
CommandItem, | |
CommandShortcut, | |
CommandSeparator, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import * as React from "react"; | |
import * as DialogPrimitive from "@radix-ui/react-dialog"; | |
import { X } from "lucide-react"; | |
import { cn } from "@/lib/utils"; | |
const Dialog = DialogPrimitive.Root; | |
const DialogTrigger = DialogPrimitive.Trigger; | |
const DialogPortal = DialogPrimitive.Portal; | |
const DialogClose = DialogPrimitive.Close; | |
const DialogOverlay = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Overlay>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Overlay | |
ref={ref} | |
className={cn( | |
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", | |
className, | |
)} | |
{...props} | |
/> | |
)); | |
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; | |
const DialogContent = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Content>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> | |
>(({ className, children, ...props }, ref) => ( | |
<DialogPortal> | |
<DialogOverlay /> | |
<DialogPrimitive.Content | |
ref={ref} | |
className={cn( | |
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", | |
className, | |
)} | |
{...props} | |
> | |
{children} | |
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> | |
<X className="size-4" /> | |
<span className="sr-only">Close</span> | |
</DialogPrimitive.Close> | |
</DialogPrimitive.Content> | |
</DialogPortal> | |
)); | |
DialogContent.displayName = DialogPrimitive.Content.displayName; | |
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} /> | |
); | |
DialogHeader.displayName = "DialogHeader"; | |
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
<div | |
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} | |
{...props} | |
/> | |
); | |
DialogFooter.displayName = "DialogFooter"; | |
const DialogTitle = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Title>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Title | |
ref={ref} | |
className={cn("text-lg font-semibold leading-none tracking-tight", className)} | |
{...props} | |
/> | |
)); | |
DialogTitle.displayName = DialogPrimitive.Title.displayName; | |
const DialogDescription = React.forwardRef< | |
React.ElementRef<typeof DialogPrimitive.Description>, | |
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> | |
>(({ className, ...props }, ref) => ( | |
<DialogPrimitive.Description | |
ref={ref} | |
className={cn("text-sm text-muted-foreground", className)} | |
{...props} | |
/> | |
)); | |
DialogDescription.displayName = DialogPrimitive.Description.displayName; | |
export { | |
Dialog, | |
DialogPortal, | |
DialogOverlay, | |
DialogClose, | |
DialogTrigger, | |
DialogContent, | |
DialogHeader, | |
DialogFooter, | |
DialogTitle, | |
DialogDescription, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import { ChevronDown, X } from "lucide-react"; | |
import { Badge } from "@/components/ui/badge"; | |
import { | |
Command, | |
CommandEmpty, | |
CommandGroup, | |
CommandInput, | |
CommandItem, | |
CommandList, | |
} from "@/components/ui/command"; | |
import { cn } from "@/lib/utils"; | |
import { useCallback, useRef, useState } from "react"; | |
import { useClickOutside } from "@/lib/use-click-outside"; | |
interface MultiSelectProps { | |
options: string[]; | |
selectedOptions: string[]; | |
onChange: (value: string[]) => void; | |
max?: number; | |
} | |
export function MultiSelect({ | |
options, | |
selectedOptions, | |
onChange, | |
max = Infinity, | |
}: MultiSelectProps) { | |
const [search, setSearch] = useState(""); | |
const [isOpen, setIsOpen] = useState(false); | |
const commandRef = useRef<HTMLDivElement>(null); | |
const inputRef = useRef<HTMLInputElement>(null); | |
const onClose = useCallback(() => setIsOpen(false), []); | |
useClickOutside(commandRef, onClose); | |
const onSelect = (option: string) => { | |
const newValues = [...selectedOptions]; | |
if (newValues.length < max) { | |
newValues.push(option); | |
} | |
onChange(newValues); | |
setSearch(""); | |
}; | |
const onRemove = (option: string) => { | |
const newValues = selectedOptions.filter((value) => value !== option); | |
onChange(newValues); | |
inputRef.current?.focus(); | |
}; | |
const onKeyDown = (e: React.KeyboardEvent) => { | |
if (e.key === "Backspace" && !search.length && selectedOptions.length > 0) { | |
e.preventDefault(); | |
const lastOption = selectedOptions[selectedOptions.length - 1]; | |
onRemove(lastOption); | |
} | |
if (e.key === "Escape") { | |
e.preventDefault(); | |
setIsOpen(false); | |
} else { | |
setIsOpen(true); | |
} | |
}; | |
const filteredOptions = options.filter((option) => !selectedOptions.includes(option)); | |
return ( | |
<Command ref={commandRef} className="relative mb-4 h-auto overflow-visible bg-secondary"> | |
<div className={cn("flex items-center gap-2", selectedOptions.length > 0 ? "px-2" : "px-3")}> | |
{selectedOptions.map((option) => ( | |
<Badge | |
key={option} | |
variant="secondary" | |
className="flex gap-1 whitespace-nowrap rounded-sm bg-accent px-2 py-1 text-xs hover:bg-accent" | |
> | |
{option} | |
<button type="button" onClick={() => onRemove(option)}> | |
<X className="size-3 text-muted-foreground hover:text-foreground" /> | |
</button> | |
</Badge> | |
))} | |
<CommandInput | |
ref={inputRef} | |
placeholder={selectedOptions.length > 0 ? "" : "Search..."} | |
value={search} | |
onValueChange={setSearch} | |
onKeyDown={onKeyDown} | |
onFocus={() => setIsOpen(true)} | |
className="flex-1 px-0" | |
/> | |
<ChevronDown className="size-4 shrink-0 text-muted-foreground" /> | |
</div> | |
{isOpen && ( | |
<CommandList className="absolute top-[calc(100%+0.25rem)] z-20 w-full rounded-md bg-popover"> | |
<CommandEmpty className="p-3 text-center text-sm text-muted-foreground"> | |
No options found. | |
</CommandEmpty> | |
<CommandGroup> | |
{filteredOptions.map((option) => ( | |
<CommandItem key={option} value={option} onSelect={onSelect} className="px-4"> | |
{option} | |
</CommandItem> | |
))} | |
</CommandGroup> | |
</CommandList> | |
)} | |
</Command> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import { useState } from "react"; | |
import { MultiSelect } from "@/components/multi-select"; | |
import { Label } from "@/components/ui/label"; | |
const categories = [ | |
"Landing Page", | |
"Portfolio", | |
"SaaS", | |
"Dashboard", | |
"E-commerce", | |
"Blog", | |
"Documentation", | |
]; | |
const maxCategories = 3; | |
export default function Page() { | |
const [selectedOptions, setSelectedOptions] = useState<string[]>([]); | |
const onSelect = (value: string[]) => { | |
setSelectedOptions(value); | |
}; | |
return ( | |
<div className="flex h-screen flex-col items-center justify-center gap-4"> | |
<div className="w-full max-w-md"> | |
<div> | |
<Label className="mb-2 block">Categories</Label> | |
<p className="mb-3 text-xs text-muted-foreground"> | |
Select up to {maxCategories} categories. | |
</p> | |
<MultiSelect | |
options={categories} | |
selectedOptions={selectedOptions} | |
onChange={onSelect} | |
max={maxCategories} | |
/> | |
</div> | |
</div> | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment