Openstatus
www.openstatus.dev
1"use client";
2
3import { Command as CommandPrimitive } from "cmdk";
4import { X } from "lucide-react";
5import * as React from "react";
6
7import { Badge } from "./badge";
8import { Command, CommandGroup, CommandItem, CommandList } from "./command";
9
10type Option = Record<"value" | "label", string | number>;
11
12export interface MultiSelectProps {
13 options?: Option[];
14 onChange?: (values: Option[]) => void;
15 placeholder?: string;
16}
17
18export const MultiSelect = ({
19 onChange,
20 options = [],
21 ...props
22}: MultiSelectProps) => {
23 const inputRef = React.useRef<HTMLInputElement>(null);
24 const [open, setOpen] = React.useState(false);
25 const [selected, setSelected] = React.useState<Option[]>([]);
26 const [inputValue, setInputValue] = React.useState("");
27
28 const handleUnselect = React.useCallback((option: Option) => {
29 setSelected((prev) => prev.filter((s) => s.value !== option.value));
30 }, []);
31
32 const handleKeyDown = React.useCallback(
33 (e: React.KeyboardEvent<HTMLDivElement>) => {
34 const input = inputRef.current;
35 if (input) {
36 if (e.key === "Delete" || e.key === "Backspace") {
37 if (input.value === "") {
38 setSelected((prev) => {
39 const newSelected = [...prev];
40 newSelected.pop();
41 return newSelected;
42 });
43 }
44 }
45 // This is not a default behaviour of the <input /> field
46 if (e.key === "Escape") {
47 input.blur();
48 }
49 }
50 },
51 []
52 );
53
54 const selectables = options.filter((option) => !selected.includes(option));
55
56 React.useEffect(() => {
57 onChange?.(selected);
58 }, [selected, onChange]);
59
60 return (
61 <Command
62 onKeyDown={handleKeyDown}
63 className="overflow-visible bg-transparent"
64 >
65 <div className="border-input ring-offset-background focus-within:ring-ring group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2">
66 <div className="flex flex-wrap gap-1">
67 {selected.map((option) => {
68 return (
69 <Badge key={option.value} variant="secondary">
70 {option.label}
71 <button
72 type="button"
73 className="ring-offset-background focus:ring-ring ml-1 rounded-full outline-hidden focus:ring-2 focus:ring-offset-2"
74 onKeyDown={(e) => {
75 if (e.key === "Enter") {
76 handleUnselect(option);
77 }
78 }}
79 onMouseDown={(e) => {
80 e.preventDefault();
81 e.stopPropagation();
82 }}
83 onClick={() => handleUnselect(option)}
84 >
85 <X className="text-muted-foreground hover:text-foreground h-3 w-3" />
86 </button>
87 </Badge>
88 );
89 })}
90 {/* Avoid having the "Search" Icon */}
91 <CommandPrimitive.Input
92 ref={inputRef}
93 value={inputValue}
94 onValueChange={setInputValue}
95 onBlur={() => setOpen(false)}
96 onFocus={() => setOpen(true)}
97 className="placeholder:text-muted-foreground ml-2 flex-1 bg-transparent outline-hidden"
98 {...props}
99 />
100 </div>
101 </div>
102 <div className="relative mt-2">
103 {open && selectables.length > 0 ? (
104 <div className="bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-hidden">
105 <CommandList>
106 <CommandGroup className="h-full overflow-auto">
107 {selectables.map((option) => {
108 return (
109 <CommandItem
110 key={option.value}
111 onMouseDown={(e) => {
112 e.preventDefault();
113 e.stopPropagation();
114 }}
115 onSelect={(_value) => {
116 setInputValue("");
117 setSelected((prev) => [...prev, option]);
118 }}
119 value={String(option.label)}
120 className={"cursor-pointer"}
121 >
122 {option.label}
123 </CommandItem>
124 );
125 })}
126 </CommandGroup>
127 </CommandList>
128 </div>
129 ) : null}
130 </div>
131 </Command>
132 );
133};