1 import React from 'react'
2 import {Button, ActionList, Box, Text} from '@primer/react'
3 import {XIcon, SearchIcon} from '@primer/octicons-react'
4 import {AnimatePresence, motion} from 'framer-motion'
5 import {FocusOn} from 'react-focus-on'
6 import TextInput from './text-input'
7 import useSiteMetadata from '../hooks/use-site-metadata'
8 import {HEADER_BAR, HEADER_HEIGHT, Z_INDEX} from '../constants'
9 import {LightTheme} from '../theme'
10 import {LinkNoUnderline} from './link'
11 import * as getNav from '../util/get-nav'
12 import omit from '../util/omit'
13 import {announce} from '../util/aria-live'
14
15 const SearchResults = ({results, getItemProps, highlightedIndex}) => {
16 const siteMetadata = useSiteMetadata()
17 if (!results || results.length === 0) {
18 announce('No results')
19 return (
20 <Box sx={{fontSize: 2, px: 3, py: 3}} aria-live="polite">
21 No results
22 </Box>
23 )
24 }
25
26 return (
27 <ActionList>
28 {results.map((item, index) => {
29 // keep the variant in the breadcrumb if we have one and its not the
30 // same as the last breadcrumb. this makes sure that variant index pages
31 // don't all appear the same in the search results
32 const variant = getNav.getVariant(getNav.getVariantRoot(item.path), item.path)
33 const hierarchy = getNav.getItemBreadcrumbs(item.path)
34 if (!variant || variant !== hierarchy[hierarchy.length - 1]?.shortName) {
35 hierarchy.pop()
36 }
37
38 return (
39 <ActionList.Item
40 key={item.path}
41 // fixes a bug between downshift and react/primer where the search result item is always disabled
42 // this is safe to remove because we know we never have any disabled search results
43 {...omit(getItemProps({item, index}), 'aria-disabled')}
44 as={LinkNoUnderline}
45 to={item.path}
46 active={highlightedIndex === index}
47 >
48 <Box sx={{display: 'flex', flexDirection: 'column', flex: '0 0 auto'}}>
49 <Text sx={{fontSize: 0}}>
50 {hierarchy.length ? hierarchy.map(s => s.shortName || s.title).join(' / ') : siteMetadata.shortName}
51 </Text>
52 <Text>{item.title}</Text>
53 </Box>
54 </ActionList.Item>
55 )
56 })}
57 </ActionList>
58 )
59 }
60
61 export const Desktop = props => {
62 const siteMetadata = useSiteMetadata()
63 const {getInputProps, getMenuProps, resultsOpen, ...rest} = props
64
65 return (
66 <Box sx={{position: 'relative'}}>
67 <TextInput
68 sx={{width: '240px'}}
69 placeholder={`Search ${siteMetadata.title}`}
70 aria-label={`Search ${siteMetadata.title}`}
71 {...getInputProps()}
72 />
73 <Box sx={{position: 'absolute', left: 0, right: 0, pt: 1}} {...getMenuProps()}>
74 {resultsOpen ? (
75 <LightTheme
76 sx={{
77 overflow: 'auto',
78 minWidth: 300,
79 maxHeight: '70vh',
80 boxShadow: 'shadow.large',
81 borderColor: 'border.muted',
82 bg: 'canvas.overlay',
83 borderRadius: 2,
84 borderWidth: 1,
85 borderStyle: 'solid',
86 }}
87 >
88 <SearchResults {...rest} />
89 </LightTheme>
90 ) : null}
91 </Box>
92 </Box>
93 )
94 }
95
96 export const Mobile = ({
97 resultsOpen,
98 getInputProps,
99 getMenuProps,
100 isMobileSearchOpen,
101 setMobileSearchOpen,
102 isForceClose,
103 resetAndClose,
104 ...rest
105 }) => {
106 const siteMetadata = useSiteMetadata()
107 const getCloseAnimation = exit => (isForceClose ? undefined : {exit})
108
109 const handleSearchToggle = React.useCallback(() => {
110 setMobileSearchOpen(true)
111 }, [setMobileSearchOpen])
112
113 return (
114 <>
115 {!isMobileSearchOpen && (
116 <Button aria-label="Search" aria-expanded={isMobileSearchOpen} onClick={handleSearchToggle}>
117 <SearchIcon />
118 </Button>
119 )}
120
121 <AnimatePresence>
122 {isMobileSearchOpen ? (
123 <FocusOn returnFocus={true} onEscapeKey={() => resetAndClose(true)}>
124 <Box
125 sx={{
126 position: 'fixed',
127 top: `${HEADER_BAR}px`,
128 left: 0,
129 right: 0,
130 bottom: 0,
131 zIndex: Z_INDEX.SEARCH_OVERLAY,
132 }}
133 >
134 <Box
135 sx={{
136 position: 'absolute',
137 top: 0,
138 left: 0,
139 right: 0,
140 bottom: 0,
141 bg: 'overlay.backdrop',
142 zIndex: -1,
143 }}
144 key="search-backdrop"
145 as={motion.div}
146 initial={{opacity: 0}}
147 animate={{opacity: 1}}
148 transition={{type: 'tween'}}
149 onClick={() => resetAndClose(true)}
150 {...getCloseAnimation({opacity: 0})}
151 />
152 <Box sx={{display: 'flex', flexDirection: 'column', height: resultsOpen ? '100%' : 'auto'}}>
153 <Box
154 sx={{
155 display: 'flex',
156 color: 'fg.default',
157 height: `${HEADER_HEIGHT}px`,
158 flex: '0 0 auto',
159 px: 3,
160 alignItems: 'center',
161 border: '1px solid',
162 borderTopWidth: 0,
163 borderLeftWidth: 0,
164 borderRightWidth: 0,
165 borderColor: 'border.muted',
166 position: 'relative',
167 bg: 'canvas.default',
168 zIndex: Z_INDEX.SEARCH_OVERLAY + 1,
169 }}
170 >
171 <motion.div
172 key="search-box"
173 initial={{scaleX: 0}}
174 animate={{scaleX: 1}}
175 {...getCloseAnimation({scaleX: 0})}
176 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
177 style={{width: '100%', originX: '100%'}}
178 >
179 <Box
180 sx={{
181 position: 'absolute',
182 top: 0,
183 bottom: 0,
184 left: 0,
185 width: '70px',
186 bg: 'canvas.default',
187 zIndex: '-1',
188 }}
189 />
190 <TextInput
191 leadingVisual={SearchIcon}
192 placeholder={`Search ${siteMetadata.title}`}
193 aria-label={`Search ${siteMetadata.title}`}
194 sx={{width: '100%'}}
195 {...getInputProps()}
196 />
197 </motion.div>
198 <Box
199 key="button-blocker"
200 as={motion.div}
201 initial={{opacity: 0}}
202 animate={{opacity: 1}}
203 {...getCloseAnimation({scaleX: 0})}
204 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
205 sx={{
206 position: 'absolute',
207 top: 0,
208 bottom: 0,
209 right: 0,
210 width: '70px',
211 bg: 'canvas.default',
212 zIndex: '-1',
213 }}
214 />
215 <Box
216 key="button"
217 as={motion.div}
218 initial={{opacity: 0}}
219 animate={{opacity: 1}}
220 {...getCloseAnimation({opacity: 0})}
221 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
222 >
223 <Button sx={{ml: 3}} aria-label="Cancel" onClick={() => resetAndClose(false)}>
224 <XIcon />
225 </Button>
226 </Box>
227 </Box>
228 <LightTheme
229 sx={{
230 display: 'flex',
231 bg: 'canvas.default',
232 flexDirection: 'column',
233 flex: '1 1 auto',
234 overflow: 'auto',
235 }}
236 style={{
237 WebkitOverflowScrolling: 'touch',
238 }}
239 {...getMenuProps()}
240 >
241 {resultsOpen ? <SearchResults {...rest} /> : null}
242 </LightTheme>
243 </Box>
244 </Box>
245 </FocusOn>
246 ) : null}
247 </AnimatePresence>
248 </>
249 )
250 }