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