Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 133 lines 4.5 kB view raw
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};