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