CommandMenu.tsx294 lines · main
1'use client'
2
3import { useBreakpoint } from 'common'
4import useDragToClose from 'common/hooks/useDragToClose'
5import { AlertTriangle, ArrowLeft, Search } from 'lucide-react'
6import { VisuallyHidden } from 'radix-ui'
7import type { HTMLAttributes, MouseEvent, PropsWithChildren, ReactElement, ReactNode } from 'react'
8import { Children, cloneElement, forwardRef, isValidElement, useEffect, useMemo } from 'react'
9import { ErrorBoundary } from 'react-error-boundary'
10import {
11 Button,
12 cn,
13 Command,
14 Dialog,
15 DialogContent,
16 DialogDescription,
17 DialogTitle,
18 KeyboardShortcut,
19} from 'ui'
20
21import { useCurrentPage, usePageComponent, usePopPage } from './hooks/pagesHooks'
22import { useQuery, useSetQuery } from './hooks/queryHooks'
23import { useCommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext'
24import {
25 useCommandMenuOpen,
26 useCommandMenuSize,
27 useSetCommandMenuOpen,
28 useSetupCommandMenuTouchEvents,
29} from './hooks/viewHooks'
30
31function 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
52const 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})
77CommandWrapper.displayName = Command.displayName
78
79function 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&apos;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
96function PageSwitch({ children }: PropsWithChildren) {
97 const PageComponent = usePageComponent()
98
99 return PageComponent ? <PageComponent /> : <CommandWrapper>{children}</CommandWrapper>
100}
101
102function 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
119function 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
172function 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
218interface CommandMenuProps extends PropsWithChildren {
219 trigger?: ReactNode
220}
221
222function 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
294export { Breadcrumb, CommandMenu, CommandMenuTrigger, CommandMenuTriggerInput, CommandWrapper }