Next.js プロジェクトが設定されていることを確認してください。そうでない場合は、作成します。
npx create-next-app my-app --typescript
cd my-app
必要な shadcn コンポーネントをインストールします。
npx shadcn@latest init
npx shadcn@latest add command popover button separator badge
components
ディレクトリにmulti-select.tsx
作成します。
// src/components/multi-select.tsx 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" ; /** * 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 ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300" , { 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" , } , } , defaultVariants : { variant : "default" , } , } ) ; /** * Props for MultiSelect component */ interface MultiSelectProps extends React . ButtonHTMLAttributes < HTMLButtonElement > , VariantProps < typeof multiSelectVariants > { /** * An array of option objects to be displayed in the multi-select component. * Each option object has a label, value, and an optional icon. */ options : { /** 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 } > ; } [ ] ; /** * 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 ; /** * 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 ; } export const MultiSelect = React . forwardRef < HTMLButtonElement , MultiSelectProps > ( ( { options , onValueChange , variant , defaultValue = [ ] , placeholder = "Select options" , animation = 0 , maxCount = 3 , modalPopover = false , asChild = false , className , ... props } , ref ) => { const [ selectedValues , setSelectedValues ] = React . useState < string [ ] > ( defaultValue ) ; const [ isPopoverOpen , setIsPopoverOpen ] = React . useState ( false ) ; const [ isAnimating , setIsAnimating ] = React . useState ( false ) ; const handleInputKeyDown = ( event : React . KeyboardEvent < HTMLInputElement > ) => { 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 = ( option : string ) => { const newSelectedValues = selectedValues . includes ( option ) ? selectedValues . filter ( ( value ) => value !== option ) : [ ... selectedValues , option ] ; setSelectedValues ( newSelectedValues ) ; onValueChange ( newSelectedValues ) ; } ; const handleClear = ( ) => { setSelectedValues ( [ ] ) ; onValueChange ( [ ] ) ; } ; const handleTogglePopover = ( ) => { setIsPopoverOpen ( ( prev ) => ! prev ) ; } ; const clearExtraOptions = ( ) => { const newSelectedValues = selectedValues . slice ( 0 , maxCount ) ; setSelectedValues ( newSelectedValues ) ; onValueChange ( newSelectedValues ) ; } ; const toggleAll = ( ) => { if ( selectedValues . length === options . length ) { handleClear ( ) ; } else { const allValues = options . map ( ( option ) => option . value ) ; setSelectedValues ( allValues ) ; onValueChange ( allValues ) ; } } ; return ( < Popover open = { isPopoverOpen } onOpenChange = { setIsPopoverOpen } modal = { modalPopover } > < PopoverTrigger asChild > < Button ref = { ref } { ... props } onClick = { handleTogglePopover } className = { cn ( "flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit" , className ) } > { selectedValues . length > 0 ? ( < div className = "flex justify-between items-center w-full" > < div className = "flex flex-wrap items-center" > { selectedValues . slice ( 0 , maxCount ) . map ( ( value ) => { const option = options . find ( ( o ) => o . value === value ) ; const IconComponent = option ?. icon ; return ( < Badge key = { value } className = { cn ( isAnimating ? "animate-bounce" : "" , multiSelectVariants ( { variant } ) ) } style = { { animationDuration : ` ${ animation } s` } } > { IconComponent && ( < IconComponent className = "h-4 w-4 mr-2" / > ) } { option ?. label } < XCircle className = "ml-2 h-4 w-4 cursor-pointer" onClick = { ( event ) => { event . stopPropagation ( ) ; toggleOption ( value ) ; } } / > < / Badge > ) ; } ) } { selectedValues . length > maxCount && ( < Badge className = { cn ( "bg-transparent text-foreground border-foreground/1 hover:bg-transparent" , isAnimating ? "animate-bounce" : "" , multiSelectVariants ( { variant } ) ) } style = { { animationDuration : ` ${ animation } s` } } > { `+ ${ selectedValues . length - maxCount } more` } < XCircle className = "ml-2 h-4 w-4 cursor-pointer" onClick = { ( event ) => { event . stopPropagation ( ) ; clearExtraOptions ( ) ; } } / > < / Badge > ) } < / div > < div className = "flex items-center justify-between" > < XIcon className = "h-4 mx-2 cursor-pointer text-muted-foreground" onClick = { ( event ) => { event . stopPropagation ( ) ; handleClear ( ) ; } } / > < Separator orientation = "vertical" className = "flex min-h-6 h-full" / > < ChevronDown className = "h-4 mx-2 cursor-pointer text-muted-foreground" / > < / div > < / div > ) : ( < div className = "flex items-center justify-between w-full mx-auto" > < span className = "text-sm text-muted-foreground mx-3" > { placeholder } < / span > < ChevronDown className = "h-4 cursor-pointer text-muted-foreground mx-2" / > < / div > ) } < / Button > < / PopoverTrigger > < PopoverContent className = "w-auto p-0" align = "start" onEscapeKeyDown = { ( ) => setIsPopoverOpen ( false ) } > < Command > < CommandInput placeholder = "Search..." onKeyDown = { handleInputKeyDown } / > < CommandList > < CommandEmpty > No results found. < / CommandEmpty > < CommandGroup > < CommandItem key = "all" onSelect = { toggleAll } className = "cursor-pointer" > < div className = { cn ( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary" , selectedValues . length === options . length ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible" ) } > < CheckIcon className = "h-4 w-4" / > < / div > < span > (Select All) < / span > < / CommandItem > { options . map ( ( option ) => { const isSelected = selectedValues . includes ( option . value ) ; return ( < CommandItem key = { option . value } onSelect = { ( ) => toggleOption ( option . value ) } className = "cursor-pointer" > < div className = { cn ( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary" , isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible" ) } > < CheckIcon className = "h-4 w-4" / > < / div > { option . icon && ( < option . icon className = "mr-2 h-4 w-4 text-muted-foreground" / > ) } < span > { option . label } < / span > < / CommandItem > ) ; } ) } < / CommandGroup > < CommandSeparator / > < CommandGroup > < div className = "flex items-center justify-between" > { selectedValues . length > 0 && ( < > < CommandItem onSelect = { handleClear } className = "flex-1 justify-center cursor-pointer" > Clear < / CommandItem > < Separator orientation = "vertical" className = "flex min-h-6 h-full" / > < / > ) } < CommandItem onSelect = { ( ) => setIsPopoverOpen ( false ) } className = "flex-1 justify-center cursor-pointer max-w-full" > Close < / CommandItem > < / div > < / CommandGroup > < / CommandList > < / Command > < / PopoverContent > { animation > 0 && selectedValues . length > 0 && ( < WandSparkles className = { cn ( "cursor-pointer my-2 text-foreground bg-background w-3 h-3" , isAnimating ? "" : "text-muted-foreground" ) } onClick = { ( ) => setIsAnimating ( ! isAnimating ) } / > ) } < / Popover > ) ; } ) ; MultiSelect . displayName = "MultiSelect" ;
page.tsx
を更新します。
// src/app/page.tsx
"use client" ;
import React , { useState } from "react" ;
import { MultiSelect } from "@/components/multi-select" ;
import { Cat , Dog , Fish , Rabbit , Turtle } from "lucide-react" ;
const frameworksList = [
{ value : "react" , label : "React" , icon : Turtle } ,
{ value : "angular" , label : "Angular" , icon : Cat } ,
{ value : "vue" , label : "Vue" , icon : Dog } ,
{ value : "svelte" , label : "Svelte" , icon : Rabbit } ,
{ value : "ember" , label : "Ember" , icon : Fish } ,
] ;
function Home ( ) {
const [ selectedFrameworks , setSelectedFrameworks ] = useState < string [ ] > ( [ "react" , "angular" ] ) ;
return (
< div className = "p-4 max-w-xl" >
< h1 className = "text-2xl font-bold mb-4" > Multi-Select Component < / h1 >
< MultiSelect
options = { frameworksList }
onValueChange = { setSelectedFrameworks }
defaultValue = { selectedFrameworks }
placeholder = "Select frameworks"
variant = "inverted"
animation = { 2 }
maxCount = { 3 }
/ >
< div className = "mt-4" >
< h2 className = "text-xl font-semibold" > Selected Frameworks: < / h2 >
< ul className = "list-disc list-inside" >
{ selectedFrameworks . map ( ( framework ) => (
< li key = { framework } > { framework } < / li >
) ) }
< / ul >
< / div >
< / div >
) ;
}
export default Home ;
npm run dev