CommandMenu.tsx294 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { useBreakpoint } from 'common' |
| 4 | import useDragToClose from 'common/hooks/useDragToClose' |
| 5 | import { AlertTriangle, ArrowLeft, Search } from 'lucide-react' |
| 6 | import { VisuallyHidden } from 'radix-ui' |
| 7 | import type { HTMLAttributes, MouseEvent, PropsWithChildren, ReactElement, ReactNode } from 'react' |
| 8 | import { Children, cloneElement, forwardRef, isValidElement, useEffect, useMemo } from 'react' |
| 9 | import { ErrorBoundary } from 'react-error-boundary' |
| 10 | import { |
| 11 | Button, |
| 12 | cn, |
| 13 | Command, |
| 14 | Dialog, |
| 15 | DialogContent, |
| 16 | DialogDescription, |
| 17 | DialogTitle, |
| 18 | KeyboardShortcut, |
| 19 | } from 'ui' |
| 20 | |
| 21 | import { useCurrentPage, usePageComponent, usePopPage } from './hooks/pagesHooks' |
| 22 | import { useQuery, useSetQuery } from './hooks/queryHooks' |
| 23 | import { useCommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext' |
| 24 | import { |
| 25 | useCommandMenuOpen, |
| 26 | useCommandMenuSize, |
| 27 | useSetCommandMenuOpen, |
| 28 | useSetupCommandMenuTouchEvents, |
| 29 | } from './hooks/viewHooks' |
| 30 | |
| 31 | function Breadcrumb({ className }: { className?: string }) { |
| 32 | const currPage = useCurrentPage() |
| 33 | const popPage = usePopPage() |
| 34 | |
| 35 | if (!currPage) return |
| 36 | |
| 37 | return ( |
| 38 | <button |
| 39 | type="button" |
| 40 | className={cn( |
| 41 | 'p-2 bg-overlay flex items-center gap-2 text-xs text-foreground-muted', |
| 42 | className |
| 43 | )} |
| 44 | onClick={popPage} |
| 45 | > |
| 46 | <ArrowLeft width={12} height={12} /> |
| 47 | {currPage.name} |
| 48 | </button> |
| 49 | ) |
| 50 | } |
| 51 | |
| 52 | const CommandWrapper = forwardRef< |
| 53 | React.ElementRef<typeof Command>, |
| 54 | React.ComponentPropsWithoutRef<typeof Command> |
| 55 | >(({ children, className, ...props }, ref) => { |
| 56 | return ( |
| 57 | <Command |
| 58 | ref={ref} |
| 59 | className={cn( |
| 60 | 'h-full w-full flex flex-col overflow-hidden', |
| 61 | '**:[[cmdk-group]]:px-2 **:[[cmdk-group]]:bg-transparent! **:[[cmdk-group-heading]]:bg-transparent! **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-border-stronger **:[[cmdk-input]]:h-12', |
| 62 | '[&_[cmdk-item]_svg]:h-5', |
| 63 | '[&_[cmdk-item]_svg]:w-5', |
| 64 | '[&_[cmdk-item]_svg]:stroke-1', |
| 65 | '[&_[cmdk-input-wrapper]_svg]:h-5', |
| 66 | '[&_[cmdk-input-wrapper]_svg]:w-5', |
| 67 | |
| 68 | '[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0', |
| 69 | className |
| 70 | )} |
| 71 | {...props} |
| 72 | > |
| 73 | {children} |
| 74 | </Command> |
| 75 | ) |
| 76 | }) |
| 77 | CommandWrapper.displayName = Command.displayName |
| 78 | |
| 79 | function CommandError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) { |
| 80 | return ( |
| 81 | <div className={cn('min-h-64', 'flex items-center justify-center')}> |
| 82 | <div className="p-10 flex flex-col items-center gap-6 mt-4"> |
| 83 | <AlertTriangle strokeWidth={1.5} size={40} /> |
| 84 | <p className="text-lg text-center"> |
| 85 | Sorry, looks like we're having some issues with the command menu! |
| 86 | </p> |
| 87 | <p className="text-sm text-center">Please try again in a bit.</p> |
| 88 | <Button size="tiny" type="secondary" onClick={resetErrorBoundary}> |
| 89 | Try again? |
| 90 | </Button> |
| 91 | </div> |
| 92 | </div> |
| 93 | ) |
| 94 | } |
| 95 | |
| 96 | function PageSwitch({ children }: PropsWithChildren) { |
| 97 | const PageComponent = usePageComponent() |
| 98 | |
| 99 | return PageComponent ? <PageComponent /> : <CommandWrapper>{children}</CommandWrapper> |
| 100 | } |
| 101 | |
| 102 | function useTouchGestures({ toggleOpen }: { toggleOpen: () => void }) { |
| 103 | const { ref, handleTouchStart, handleTouchMove, handleTouchEnd } = useDragToClose({ |
| 104 | onClose: toggleOpen, |
| 105 | }) |
| 106 | |
| 107 | const setupTouchHandlers = useSetupCommandMenuTouchEvents() |
| 108 | const touchHandlers = useMemo( |
| 109 | () => ({ handleTouchStart, handleTouchMove, handleTouchEnd }), |
| 110 | [handleTouchStart, handleTouchMove, handleTouchEnd] |
| 111 | ) |
| 112 | useEffect(() => { |
| 113 | setupTouchHandlers(touchHandlers) |
| 114 | }, [touchHandlers]) |
| 115 | |
| 116 | return { ref } |
| 117 | } |
| 118 | |
| 119 | function CommandMenuTrigger({ children }: PropsWithChildren) { |
| 120 | const open = useCommandMenuOpen() |
| 121 | const setOpen = useSetCommandMenuOpen() |
| 122 | const telemetryContext = useCommandMenuTelemetryContext() |
| 123 | |
| 124 | const childFromProps = Children.only(children) as ReactElement< |
| 125 | { |
| 126 | onClick?: (event: MouseEvent) => void |
| 127 | onOpen?: (open: boolean) => void |
| 128 | } & HTMLAttributes<HTMLElement> |
| 129 | > |
| 130 | if (!childFromProps || !isValidElement(childFromProps)) return null |
| 131 | |
| 132 | const handleOpen = () => { |
| 133 | setOpen(!open) |
| 134 | childFromProps.props.onOpen?.(!open) |
| 135 | |
| 136 | // Send telemetry when opening via click |
| 137 | if (!open && telemetryContext?.onTelemetry) { |
| 138 | const event = { |
| 139 | action: 'command_menu_opened' as const, |
| 140 | properties: { |
| 141 | trigger_type: 'search_input' as const, |
| 142 | app: telemetryContext.app, |
| 143 | }, |
| 144 | groups: {}, |
| 145 | } |
| 146 | |
| 147 | telemetryContext.onTelemetry(event) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | const childWithClickHandler = cloneElement(childFromProps, { |
| 152 | onClick: handleOpen, |
| 153 | 'aria-haspopup': 'dialog', |
| 154 | 'aria-expanded': open, |
| 155 | 'aria-controls': 'command-menu-dialog-content', |
| 156 | className: cn( |
| 157 | 'h-10 px-4 py-2', |
| 158 | 'inline-flex items-center justify-center', |
| 159 | 'whitespace-nowrap', |
| 160 | 'rounded-md border border-input bg-background', |
| 161 | 'text-sm', |
| 162 | 'hover:bg-accent hover:text-accent-foreground', |
| 163 | 'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', |
| 164 | 'disabled:pointer-events-none disabled:opacity-50', |
| 165 | 'transition-colors', |
| 166 | childFromProps.props.className |
| 167 | ), |
| 168 | }) |
| 169 | return childWithClickHandler |
| 170 | } |
| 171 | |
| 172 | function CommandMenuTriggerInput({ |
| 173 | className, |
| 174 | placeholder = 'Search...', |
| 175 | showShortcut = true, |
| 176 | }: { |
| 177 | className?: string |
| 178 | placeholder?: string | React.ReactNode |
| 179 | showShortcut?: boolean |
| 180 | }) { |
| 181 | return ( |
| 182 | <CommandMenuTrigger> |
| 183 | <button |
| 184 | type="button" |
| 185 | className={cn( |
| 186 | 'group', |
| 187 | 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', |
| 188 | 'pl-1.5 md:pl-2 pr-1', |
| 189 | 'flex items-center justify-between', |
| 190 | 'bg-surface-100/75 text-foreground-lighter border', |
| 191 | 'hover:bg-surface-100/100 hover:border-stronger', |
| 192 | 'focus-visible:outline-hidden focus-visible:ring-4 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background', |
| 193 | 'transition', |
| 194 | className |
| 195 | )} |
| 196 | > |
| 197 | <div className="flex items-center space-x-1.5 text-foreground-lighter"> |
| 198 | <Search |
| 199 | size={16} |
| 200 | strokeWidth={1.5} |
| 201 | className="group-hover:text-foreground-light transition-colors" |
| 202 | /> |
| 203 | <p className="flex text-xs pr-2 text-foreground-muted">{placeholder}</p> |
| 204 | </div> |
| 205 | {showShortcut && ( |
| 206 | <span aria-hidden="true"> |
| 207 | <KeyboardShortcut |
| 208 | keys={['Meta', 'k']} |
| 209 | className="command-shortcut hidden md:inline-flex h-full border border-default bg-surface-300 text-foreground-lighter shadow-xs shadow-background-surface-100" |
| 210 | /> |
| 211 | </span> |
| 212 | )} |
| 213 | </button> |
| 214 | </CommandMenuTrigger> |
| 215 | ) |
| 216 | } |
| 217 | |
| 218 | interface CommandMenuProps extends PropsWithChildren { |
| 219 | trigger?: ReactNode |
| 220 | } |
| 221 | |
| 222 | function CommandMenu({ children, trigger }: CommandMenuProps) { |
| 223 | const open = useCommandMenuOpen() |
| 224 | const setOpen = useSetCommandMenuOpen() |
| 225 | |
| 226 | const isMobile = useBreakpoint('sm') |
| 227 | |
| 228 | const size = useCommandMenuSize() |
| 229 | |
| 230 | const page = useCurrentPage() |
| 231 | const popPage = usePopPage() |
| 232 | |
| 233 | const query = useQuery() |
| 234 | const setQuery = useSetQuery() |
| 235 | |
| 236 | const telemetryContext = useCommandMenuTelemetryContext() |
| 237 | |
| 238 | const { ref: contentRef } = useTouchGestures({ toggleOpen: () => setOpen(!open) }) |
| 239 | |
| 240 | const handleOpenChange = (newOpen: boolean) => { |
| 241 | if (!newOpen && open && telemetryContext?.onTelemetry) { |
| 242 | telemetryContext.onTelemetry({ |
| 243 | action: 'command_menu_closed', |
| 244 | properties: { app: telemetryContext.app }, |
| 245 | groups: {}, |
| 246 | }) |
| 247 | } |
| 248 | setOpen(newOpen) |
| 249 | } |
| 250 | |
| 251 | return ( |
| 252 | <Dialog open={open} onOpenChange={handleOpenChange}> |
| 253 | {trigger} |
| 254 | <DialogContent |
| 255 | id="command-menu-dialog-content" |
| 256 | hideClose |
| 257 | forceMount |
| 258 | ref={contentRef} |
| 259 | onOpenAutoFocus={(e) => isMobile && e.preventDefault()} |
| 260 | onInteractOutside={() => handleOpenChange(false)} |
| 261 | onEscapeKeyDown={(e) => { |
| 262 | e.preventDefault() |
| 263 | return query ? setQuery('') : page ? popPage() : handleOpenChange(false) |
| 264 | }} |
| 265 | size={size} |
| 266 | className={cn( |
| 267 | 'relative flex flex-col my-0 mx-auto rounded-t-lg', |
| 268 | 'h-[85dvh] mt-[15vh] md:max-h-[500px] md:mt-0 left-0 bottom-0 md:bottom-auto', |
| 269 | '!animate-in !slide-in-from-bottom-[85%] duration-300!', |
| 270 | 'data-[state=closed]:!animate-out data-[state=closed]:!slide-out-to-bottom', |
| 271 | // Remove defaults set from primitive component |
| 272 | '!slide-in-from-left-[0%] :!slide-in-from-top-[0%]', |
| 273 | // Remove defaults set from primitive component |
| 274 | '!slide-out-to-left-[0%] !slide-out-to-top-[0%]', |
| 275 | 'md:data-[state=open]:!slide-in-from-bottom-[0%] md:data-[state=closed]:!slide-out-to-bottom-[0%]', |
| 276 | 'md:data-[state=open]:!zoom-in-95 md:data-[state=closed]:!zoom-out-95' |
| 277 | )} |
| 278 | dialogOverlayProps={{ |
| 279 | className: cn('overflow-hidden flex data-closed:delay-100'), |
| 280 | }} |
| 281 | > |
| 282 | <VisuallyHidden.VisuallyHidden> |
| 283 | <DialogTitle>Command menu</DialogTitle> |
| 284 | <DialogDescription>Type a command or search</DialogDescription> |
| 285 | </VisuallyHidden.VisuallyHidden> |
| 286 | <ErrorBoundary FallbackComponent={CommandError}> |
| 287 | <PageSwitch>{children}</PageSwitch> |
| 288 | </ErrorBoundary> |
| 289 | </DialogContent> |
| 290 | </Dialog> |
| 291 | ) |
| 292 | } |
| 293 | |
| 294 | export { Breadcrumb, CommandMenu, CommandMenuTrigger, CommandMenuTriggerInput, CommandWrapper } |