Created
February 12, 2024 05:38
-
-
Save hisamafahri/9399e9ff3d9bb306d95785a3f4482fff to your computer and use it in GitHub Desktop.
A multi-select component built with cmdk and shadcn components.
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"; | |
// Ref: | |
// https://github.com/shadcn-ui/ui/issues/66#issuecomment-1587535478 | |
// https://github.com/mxkaske/mxkaske.dev/blob/8d92a57ae68050211f59cd06d50d1023941c2ee0/components/craft/fancy-multi-select.tsx | |
import * as React from "react"; | |
import { X } from "lucide-react"; | |
import { Badge } from "@/components/ui/badge"; | |
import { | |
Command, | |
CommandGroup, | |
CommandItem, | |
} from "@/components/ui/command"; | |
import { Command as CommandPrimitive } from "cmdk"; | |
type Framework = Record<"value" | "label", string>; | |
const FRAMEWORKS = [ | |
{ | |
value: "next.js", | |
label: "Next.js", | |
}, | |
{ | |
value: "sveltekit", | |
label: "SvelteKit", | |
}, | |
{ | |
value: "nuxt.js", | |
label: "Nuxt.js", | |
}, | |
{ | |
value: "remix", | |
label: "Remix", | |
}, | |
{ | |
value: "astro", | |
label: "Astro", | |
}, | |
{ | |
value: "wordpress", | |
label: "WordPress", | |
}, | |
{ | |
value: "express.js", | |
label: "Express.js", | |
}, | |
{ | |
value: "nest.js", | |
label: "Nest.js", | |
} | |
] satisfies Framework[]; | |
export function FancyMultiSelect() { | |
const inputRef = React.useRef<HTMLInputElement>(null); | |
const [open, setOpen] = React.useState(false); | |
const [selected, setSelected] = React.useState<Framework[]>([FRAMEWORKS[4]]); | |
const [inputValue, setInputValue] = React.useState(""); | |
const handleUnselect = React.useCallback((framework: Framework) => { | |
setSelected(prev => prev.filter(s => s.value !== framework.value)); | |
}, []); | |
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLDivElement>) => { | |
const input = inputRef.current | |
if (input) { | |
if (e.key === "Delete" || e.key === "Backspace") { | |
if (input.value === "") { | |
setSelected(prev => { | |
const newSelected = [...prev]; | |
newSelected.pop(); | |
return newSelected; | |
}) | |
} | |
} | |
// This is not a default behaviour of the <input /> field | |
if (e.key === "Escape") { | |
input.blur(); | |
} | |
} | |
}, []); | |
const selectables = FRAMEWORKS.filter(framework => !selected.includes(framework)); | |
return ( | |
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-transparent"> | |
<div | |
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" | |
> | |
<div className="flex gap-1 flex-wrap"> | |
{selected.map((framework) => { | |
return ( | |
<Badge key={framework.value} variant="secondary"> | |
{framework.label} | |
<button | |
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
onKeyDown={(e) => { | |
if (e.key === "Enter") { | |
handleUnselect(framework); | |
} | |
}} | |
onMouseDown={(e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
}} | |
onClick={() => handleUnselect(framework)} | |
> | |
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" /> | |
</button> | |
</Badge> | |
) | |
})} | |
{/* Avoid having the "Search" Icon */} | |
<CommandPrimitive.Input | |
ref={inputRef} | |
value={inputValue} | |
onValueChange={setInputValue} | |
onBlur={() => setOpen(false)} | |
onFocus={() => setOpen(true)} | |
placeholder="Select frameworks..." | |
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1" | |
/> | |
</div> | |
</div> | |
<div className="relative mt-2"> | |
{open && selectables.length > 0 ? | |
<div className="absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"> | |
<CommandGroup className="h-full overflow-auto"> | |
{selectables.map((framework) => { | |
return ( | |
<CommandItem | |
key={framework.value} | |
onMouseDown={(e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
}} | |
onSelect={(value) => { | |
setInputValue("") | |
setSelected(prev => [...prev, framework]) | |
}} | |
className={"cursor-pointer"} | |
> | |
{framework.label} | |
</CommandItem> | |
); | |
})} | |
</CommandGroup> | |
</div> | |
: null} | |
</div> | |
</Command > | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment