a tool for shared writing and social publishing
at main 166 lines 5.3 kB view raw
1import { useEffect, useRef, useState } from "react"; 2import * as Popover from "@radix-ui/react-popover"; 3import { NestedCardThemeProvider } from "components/ThemeManager/ThemeProvider"; 4import { Input } from "./Input"; 5 6export const Combobox = ({ 7 results, 8 onSelect, 9 children, 10 onOpenChange, 11 highlighted, 12 setHighlighted, 13 searchValue, 14 setSearchValue, 15 showSearch, 16 trigger, 17 triggerClassName, 18 sideOffset, 19 open: openProp, 20}: { 21 children: React.ReactNode; 22 trigger?: React.ReactNode; 23 triggerClassName?: string; 24 results: string[]; 25 onSelect?: () => void; 26 onOpenChange?: (open: boolean) => void; 27 highlighted: string | undefined; 28 setHighlighted: (h: string | undefined) => void; 29 searchValue?: string; 30 setSearchValue?: (s: string) => void; 31 showSearch?: boolean; 32 sideOffset?: number; 33 open?: boolean; 34}) => { 35 let ref = useRef<HTMLDivElement>(null); 36 let [internalOpen, setInternalOpen] = useState(false); 37 let open = openProp ?? internalOpen; 38 39 useEffect(() => { 40 if (!highlighted || !results.find((result) => result === highlighted)) 41 setHighlighted(results[0]); 42 if (results.length === 1) { 43 setHighlighted(results[0]); 44 } 45 }, [results, setHighlighted, highlighted]); 46 47 useEffect(() => { 48 let listener = async (e: KeyboardEvent) => { 49 let reverseDir = ref.current?.dataset.side === "top"; 50 let currentHighlightIndex = results.findIndex( 51 (result) => highlighted && result === highlighted, 52 ); 53 54 if (reverseDir ? e.key === "ArrowUp" : e.key === "ArrowDown") { 55 setHighlighted( 56 results[ 57 currentHighlightIndex === results.length - 1 || 58 currentHighlightIndex === undefined 59 ? 0 60 : currentHighlightIndex + 1 61 ], 62 ); 63 return; 64 } 65 if (reverseDir ? e.key === "ArrowDown" : e.key === "ArrowUp") { 66 setHighlighted( 67 results[ 68 currentHighlightIndex === 0 || 69 currentHighlightIndex === undefined || 70 currentHighlightIndex === -1 71 ? results.length - 1 72 : currentHighlightIndex - 1 73 ], 74 ); 75 return; 76 } 77 78 // on enter, select the highlighted item 79 if (e.key === "Enter") { 80 e.preventDefault(); 81 onSelect?.(); 82 setInternalOpen(false); 83 } 84 }; 85 86 window.addEventListener("keydown", listener); 87 88 return () => window.removeEventListener("keydown", listener); 89 }, [highlighted, setHighlighted, results]); 90 91 return ( 92 <Popover.Root 93 open={open} 94 onOpenChange={(newOpen) => { 95 setInternalOpen(newOpen); 96 onOpenChange?.(newOpen); 97 }} 98 > 99 <Popover.Trigger asChild className={`${triggerClassName}`}> 100 <div>{trigger}</div> 101 </Popover.Trigger> 102 <Popover.Portal> 103 <Popover.Content 104 align="start" 105 sideOffset={sideOffset ? sideOffset : 16} 106 collisionPadding={16} 107 ref={ref} 108 onOpenAutoFocus={(e) => e.preventDefault()} 109 className={` 110 commandMenuContent group/cmd-menu 111 z-20 w-[264px] 112 flex data-[side=top]:items-end items-start 113 `} 114 > 115 <NestedCardThemeProvider> 116 <div 117 className={`commandMenuResults w-full max-h-(--radix-popover-content-available-height) overflow-auto no-scrollbar flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page gap-0.5 border border-border rounded-md shadow-md `} 118 > 119 {showSearch && setSearchValue ? ( 120 <Input 121 autoFocus 122 placeholder="search…" 123 className={`px-3 pb-1 pt-1 text-primary focus-within:outline-none! focus:outline-none! focus-visible:outline-none! appearance-none bg-bg-page border-border-light border-b group-data-[side=top]/cmd-menu:border-t group-data-[side=top]/cmd-menu:border-b-0 124 sticky group-data-[side=top]/cmd-menu:bottom-0 top-0`} 125 value={searchValue} 126 onChange={(e) => setSearchValue(e.target.value)} 127 onClick={(e) => e.stopPropagation()} 128 onPointerDown={(e) => e.stopPropagation()} 129 /> 130 ) : null} 131 <div className="space h-1 w-full bg-transparent" /> 132 {children} 133 <div className="space h-1 w-full bg-transparent" /> 134 </div> 135 </NestedCardThemeProvider> 136 </Popover.Content> 137 </Popover.Portal> 138 </Popover.Root> 139 ); 140}; 141 142export const ComboboxResult = (props: { 143 result: string; 144 children: React.ReactNode; 145 onSelect: () => void; 146 highlighted: string | undefined; 147 setHighlighted: (state: string | undefined) => void; 148 className?: string; 149}) => { 150 let isHighlighted = props.highlighted === props.result; 151 152 return ( 153 <button 154 className={`comboboxResult menuItem text-secondary font-normal! py-0.5! mx-1 ${props.className} ${isHighlighted && "bg-[var(--accent-light)]!"}`} 155 onMouseOver={() => { 156 props.setHighlighted(props.result); 157 }} 158 onMouseDown={(e) => { 159 e.preventDefault(); 160 props.onSelect(); 161 }} 162 > 163 <div className="truncate flex items-center">{props.children}</div> 164 </button> 165 ); 166};