CommandMenuInput.tsx140 lines · main
1'use client'
2
3import { useBreakpoint, useDebounce } from 'common'
4import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
5import type React from 'react'
6import { cn, CommandInput } from 'ui'
7
8import { useQuery, useSetQuery } from './hooks/queryHooks'
9import { useCommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext'
10
11const INPUT_TYPED_EVENT_DEBOUNCE_TIME = 2000 // 2s
12
13function useFocusInputOnWiderScreens(ref: React.ForwardedRef<HTMLInputElement>) {
14 const isBelowSm = useBreakpoint('sm')
15 const isBelowSmSynchronous = useRef(isBelowSm)
16 isBelowSmSynchronous.current = isBelowSm
17
18 const internalRef = useRef<HTMLInputElement | null>(null)
19 const combinedRef = (element: HTMLInputElement) => {
20 if (ref instanceof Function) {
21 ref(element)
22 } else if (!!ref) {
23 ref.current = element
24 }
25 internalRef.current = element
26 }
27
28 useEffect(() => {
29 // This will always be false in the first iteration (since isBelowSm
30 // switches from false -> true on narrow screens). To avoid a preemptive
31 // focus, we need to delay this. But then we need to access the current
32 // value of isBelowSm, not the stale value, which explains the business
33 // with syncing state into a ref above.
34 setTimeout(() => {
35 if (!isBelowSmSynchronous.current) {
36 internalRef.current?.focus()
37 }
38 })
39 }, [])
40
41 return combinedRef
42}
43
44const CommandMenuInput = forwardRef<
45 React.ElementRef<typeof CommandInput>,
46 React.ComponentPropsWithoutRef<typeof CommandInput>
47>(({ className, ...props }, ref) => {
48 const inputRef = useFocusInputOnWiderScreens(ref)
49
50 const query = useQuery()
51 const setQuery = useSetQuery()
52
53 const [inputValue, setInputValue] = useState(query)
54 useEffect(() => {
55 setInputValue(query)
56 previousValueRef.current = query
57 }, [query])
58
59 // Get telemetry context
60 const telemetryContext = useCommandMenuTelemetryContext()
61 const previousValueRef = useRef<string>(inputValue)
62
63 const inputTelemetryEvent = useCallback(
64 (value: string) => {
65 if (telemetryContext?.onTelemetry) {
66 const event = {
67 action: 'command_menu_search_submitted' as const,
68 properties: {
69 value: value,
70 app: telemetryContext.app,
71 },
72 groups: {},
73 }
74 telemetryContext.onTelemetry(event)
75 }
76 },
77 [telemetryContext]
78 )
79
80 const debouncedTelemetry = useDebounce(
81 useCallback(() => {
82 inputTelemetryEvent(inputValue)
83 previousValueRef.current = inputValue
84 }, [inputTelemetryEvent, inputValue]),
85 INPUT_TYPED_EVENT_DEBOUNCE_TIME
86 )
87
88 const handleValueChange = useCallback(
89 (value: string) => {
90 setInputValue(value)
91
92 // Only trigger telemetry if the user is adding characters (not removing with backspace)
93 const isAddingCharacters = value.length > previousValueRef.current.length
94
95 if (!isAddingCharacters) {
96 previousValueRef.current = value
97 return
98 }
99
100 // Trigger debounced telemetry
101 debouncedTelemetry()
102 },
103 [debouncedTelemetry]
104 )
105
106 // To handle CJK input
107 const [imeComposing, setImeComposing] = useState(false)
108 useEffect(() => {
109 if (!imeComposing) {
110 setQuery(inputValue)
111 }
112 }, [inputValue, imeComposing])
113
114 return (
115 <div className="relative w-full" cmdk-input-wrapper="">
116 <CommandInput
117 // Focus needs to be manually handled to check breakpoint first, due to
118 // delays from useEffect
119 autoFocus={false}
120 ref={inputRef}
121 value={inputValue}
122 onValueChange={handleValueChange}
123 placeholder="Run a command or search..."
124 onCompositionStart={() => setImeComposing(true)}
125 onCompositionEnd={() => setImeComposing(false)}
126 className={cn(
127 'flex h-11 w-full rounded-md bg-transparent px-2 y-4 md:py-7 outline-hidden',
128 'focus:shadow-none focus:ring-transparent',
129 'text-base text-foreground-light placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 border-0',
130 className
131 )}
132 {...props}
133 />
134 </div>
135 )
136})
137
138CommandMenuInput.displayName = 'CommandMenuInput'
139
140export { CommandMenuInput }