Skip to content

Instantly share code, notes, and snippets.

@hisamafahri
Created February 12, 2024 05:38
Show Gist options
  • Save hisamafahri/9399e9ff3d9bb306d95785a3f4482fff to your computer and use it in GitHub Desktop.
Save hisamafahri/9399e9ff3d9bb306d95785a3f4482fff to your computer and use it in GitHub Desktop.
A multi-select component built with cmdk and shadcn components.
"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