1 import React from 'react'
2 import {useCombobox} from 'downshift'
3 import {navigate, graphql, useStaticQuery} from 'gatsby'
4 import {useIsMobile} from './use-breakpoint'
5 import usePage from './use-page'
6 import * as getNav from '../util/get-nav'
7 import {CLI_PATH} from '../constants'
8
9 const useSearchData = () => {
10 const data = useStaticQuery(graphql`
11 {
12 allMdx {
13 nodes {
14 id
15 frontmatter {
16 title
17 }
18 body
19 }
20 }
21 allSitePage {
22 nodes {
23 path
24 pageContext
25 }
26 }
27 }
28 `)
29
30 return React.useMemo(() => {
31 const mdxNodes = data.allMdx.nodes.reduce((map, obj) => {
32 map[obj.id] = obj
33 return map
34 }, {})
35
36 return data.allSitePage.nodes
37 .filter(node => mdxNodes[node.pageContext?.mdxId] != null)
38 .map(node => {
39 const mdxNode = mdxNodes[node.pageContext.mdxId]
40 return {
41 path: node.path,
42 title: mdxNode.frontmatter.title,
43 body: mdxNode.body,
44 }
45 })
46 }, [data])
47 }
48
49 const useCliVersion = () => {
50 return getNav.getCurrentOrDefaultVariant(
51 getNav.getItem(getNav.getVariantRoot(`${CLI_PATH}/`, {stripTrailing: false})),
52 usePage().location.pathname,
53 )
54 }
55
56 const useSearchCombobox = (results, setQuery) => {
57 const isMobile = useIsMobile()
58
59 const combobox = useCombobox({
60 id: 'search-box',
61 items: results || [],
62 selectedItem: null,
63 onInputValueChange: ({inputValue}) => setQuery(inputValue),
64 onSelectedItemChange: ({selectedItem}) => {
65 if (selectedItem) {
66 navigate(selectedItem.path)
67 resetAndClose(true)
68 }
69 },
70 itemToString: item => (item ? item.title : ''),
71 stateReducer: (state, {type, changes}) => {
72 switch (type) {
73 case useCombobox.stateChangeTypes.InputChange:
74 if (!changes.inputValue) {
75 // Close the menu if the input is empty.
76 return {...changes, isOpen: false}
77 }
78 break
79 case useCombobox.stateChangeTypes.InputBlur:
80 if (isMobile) {
81 // Don't let a blur event change the state of `inputValue` or `isOpen`.
82 return {
83 ...changes,
84 inputValue: state.inputValue,
85 isOpen: state.isOpen,
86 }
87 }
88 break
89 default:
90 break
91 }
92 return changes
93 },
94 })
95
96 const [isMobileSearchOpen, setMobileSearchOpen] = React.useState(false)
97 const [isForceClose, setForceClose] = React.useState(false)
98 const forceCloseRef = React.useRef(false)
99
100 const resetAndClose = React.useCallback(
101 force => {
102 combobox.reset()
103 if (force === true) {
104 setForceClose(true)
105 } else {
106 setMobileSearchOpen(false)
107 }
108 },
109 [combobox, setMobileSearchOpen],
110 )
111
112 // if forceClose is set then we wait until the exit animation props have
113 // been removed in the component and then set mobile search to false
114 React.useEffect(() => {
115 if (isMobileSearchOpen && isForceClose && !forceCloseRef.current) {
116 setMobileSearchOpen(false)
117 }
118 forceCloseRef.current = isForceClose
119 }, [forceCloseRef, isForceClose, isMobileSearchOpen, setMobileSearchOpen])
120
121 // always reset force close any time mobile search is closed
122 React.useEffect(() => {
123 if (!isMobileSearchOpen) {
124 setForceClose(false)
125 }
126 }, [setForceClose, isMobileSearchOpen])
127
128 // Fixes focus behavior on iOS where the input gets focus styles but not the
129 // actual focus after animating open.
130 const inputRef = React.useRef()
131 React.useEffect(() => {
132 if (isMobileSearchOpen) {
133 inputRef.current.focus()
134 }
135 }, [inputRef, isMobileSearchOpen])
136
137 return {
138 ...combobox,
139 isMobileSearchOpen,
140 isForceClose,
141 setMobileSearchOpen,
142 resetAndClose,
143 getInputProps: (...props) => combobox.getInputProps({ref: inputRef, ...props}),
144 }
145 }
146
147 function useSearch() {
148 const [query, setQuery] = React.useState()
149 const [results, setResults] = React.useState(null)
150 const queryRef = React.useRef()
151 const items = useSearchData()
152 const {url: cliUrl} = useCliVersion()
153 const worker = React.useRef()
154
155 const handleSearchResults = React.useCallback(({data}) => {
156 if (data.query && data.results && data.query === queryRef.current) {
157 setResults(data.results)
158 }
159 }, [])
160
161 React.useEffect(() => {
162 worker.current = new Worker(new URL('../util/search.worker.js', import.meta.url))
163 }, [])
164
165 React.useEffect(() => {
166 worker.current.addEventListener('message', handleSearchResults)
167 }, [worker, handleSearchResults])
168
169 React.useEffect(() => {
170 worker.current.postMessage({items})
171 }, [worker, items])
172
173 React.useEffect(() => {
174 worker.current.postMessage({cli: {root: CLI_PATH, current: cliUrl}})
175 }, [worker, cliUrl])
176
177 React.useEffect(() => {
178 queryRef.current = query
179
180 if (query) {
181 worker.current.postMessage({query})
182 } else {
183 setResults(null)
184 }
185 }, [worker, query])
186
187 const combobox = useSearchCombobox(results, setQuery)
188
189 return {
190 ...combobox,
191 results,
192 resultsOpen: !!(combobox.isOpen && results),
193 }
194 }
195
196 export default useSearch