CommandProvider.tsx132 lines · main
1'use client'
2
3import { useConstant } from 'common'
4import { useRouter as useLegacyRouter } from 'next/compat/router'
5import { useCallback, useEffect, useMemo, type PropsWithChildren } from 'react'
6
7import { CommandContext } from '../internal/Context'
8import { initCommandsState } from '../internal/state/commandsState'
9import { initPagesState } from '../internal/state/pagesState'
10import { initQueryState } from '../internal/state/queryState'
11import { initViewState } from '../internal/state/viewState'
12import {
13 useCommandMenuTelemetry,
14 type CommandMenuTelemetryCallback,
15} from './hooks/useCommandMenuTelemetry'
16import { CommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext'
17import { CrossCompatRouterContext } from './hooks/useCrossCompatRouter'
18import {
19 useCommandMenuOpen,
20 useResetCommandMenu,
21 useSetCommandMenuOpen,
22 useToggleCommandMenu,
23} from './hooks/viewHooks'
24
25const CommandProviderInternal = ({ children }: PropsWithChildren) => {
26 const combinedState = useConstant(() => ({
27 commandsState: initCommandsState(),
28 pagesState: initPagesState(),
29 queryState: initQueryState(),
30 viewState: initViewState(),
31 }))
32
33 return <CommandContext.Provider value={combinedState}>{children}</CommandContext.Provider>
34}
35
36// This is a component not a hook so it can access the wrapping context.
37const CommandShortcut = ({
38 openKey,
39 app,
40 onTelemetry,
41}: {
42 openKey: string
43 app?: 'studio' | 'docs' | 'www'
44 onTelemetry?: CommandMenuTelemetryCallback
45}) => {
46 const toggleOpen = useToggleCommandMenu()
47 const isOpen = useCommandMenuOpen()
48 const { sendTelemetry } = useCommandMenuTelemetry({
49 app: app ?? 'studio',
50 onTelemetry,
51 })
52
53 useEffect(() => {
54 if (openKey === '') return
55
56 const handleKeydown = (evt: KeyboardEvent) => {
57 const usesPrimaryModifier = evt.metaKey || evt.ctrlKey
58 const otherModifiersActive = evt.altKey || evt.shiftKey
59 if (evt.key === openKey && usesPrimaryModifier && !otherModifiersActive) {
60 evt.preventDefault()
61 toggleOpen()
62 !isOpen && sendTelemetry('keyboard_shortcut')
63 }
64 }
65
66 document.addEventListener('keydown', handleKeydown)
67
68 return () => document.removeEventListener('keydown', handleKeydown)
69 }, [isOpen, openKey, sendTelemetry, toggleOpen])
70
71 return null
72}
73
74function CloseOnNavigation({ children }: PropsWithChildren) {
75 const setIsOpen = useSetCommandMenuOpen()
76 const resetCommandMenu = useResetCommandMenu()
77
78 const legacyRouter = useLegacyRouter()
79 const isUsingLegacyRouting = !!legacyRouter
80
81 const completeNavigation = useCallback(() => {
82 setIsOpen(false)
83 resetCommandMenu()
84 }, [resetCommandMenu, setIsOpen])
85
86 const ctx = useMemo(
87 () => ({
88 onPendingEnd: new Set([completeNavigation]),
89 }),
90 [completeNavigation]
91 )
92
93 useEffect(() => {
94 if (!isUsingLegacyRouting) return
95
96 legacyRouter.events.on('routeChangeComplete', completeNavigation)
97 return () => legacyRouter.events.off('routeChangeComplete', completeNavigation)
98 }, [isUsingLegacyRouting, legacyRouter])
99
100 return (
101 <CrossCompatRouterContext.Provider value={ctx}>{children}</CrossCompatRouterContext.Provider>
102 )
103}
104
105interface CommandProviderProps extends PropsWithChildren {
106 /**
107 * The keyboard shortcut that opens the command menu when used in
108 * combination with the meta key.
109 *
110 * Defaults to `k`. Pass an empty string to disable the keyboard shortcut.
111 */
112 openKey?: string
113 /**
114 * The app where the command menu is being used
115 */
116 app?: 'studio' | 'docs' | 'www'
117 /**
118 * Optional callback to send telemetry events
119 */
120 onTelemetry?: CommandMenuTelemetryCallback
121}
122
123const CommandProvider = ({ children, openKey, app, onTelemetry }: CommandProviderProps) => (
124 <CommandProviderInternal>
125 <CommandMenuTelemetryContext.Provider value={{ app: app ?? 'studio', onTelemetry }}>
126 <CommandShortcut openKey={openKey ?? 'k'} app={app} onTelemetry={onTelemetry} />
127 <CloseOnNavigation>{children}</CloseOnNavigation>
128 </CommandMenuTelemetryContext.Provider>
129 </CommandProviderInternal>
130)
131
132export { CommandProvider }