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
49 sx={{display: 'flex', flexDirection: 'column', flex: '0 0 auto'}}
50 aria-label={`${item.title}${hierarchy.length ? ` in ${hierarchy.map(s => s.shortName || s.title).join(' / ')}` : ` in ${siteMetadata.shortName}`}, ${index + 1} of ${results.length}`}
51 >
52 <Text sx={{fontSize: 0}}>
53 {hierarchy.length ? hierarchy.map(s => s.shortName || s.title).join(' / ') : siteMetadata.shortName}
54 </Text>
55 <Text>{item.title}</Text>
56 </Box>
57 </ActionList.Item>
58 )
59 })}
60 </ActionList>
61 )
62 }
63
64 export const Desktop = props => {
65 const siteMetadata = useSiteMetadata()
66 const {getInputProps, getMenuProps, resultsOpen, ...rest} = props
67
68 return (
69 <Box sx={{position: 'relative'}}>
70 <TextInput
71 sx={{width: '240px'}}
72 placeholder={`Search ${siteMetadata.title}`}
73 aria-label={`Search ${siteMetadata.title}`}
74 {...getInputProps()}
75 />
76 <Box sx={{position: 'absolute', left: 0, right: 0, pt: 1}} {...getMenuProps()}>
77 {resultsOpen ? (
78 <LightTheme
79 sx={{
80 overflow: 'auto',
81 minWidth: 300,
82 maxHeight: '70vh',
83 boxShadow: 'shadow.large',
84 borderColor: 'border.muted',
85 bg: 'canvas.overlay',
86 borderRadius: 2,
87 borderWidth: 1,
88 borderStyle: 'solid',
89 }}
90 >
91 <SearchResults {...rest} />
92 </LightTheme>
93 ) : null}
94 </Box>
95 </Box>
96 )
97 }
98
99 export const Mobile = ({
100 resultsOpen,
101 getInputProps,
102 getMenuProps,
103 isMobileSearchOpen,
104 setMobileSearchOpen,
105 isForceClose,
106 resetAndClose,
107 ...rest
108 }) => {
109 const siteMetadata = useSiteMetadata()
110 const getCloseAnimation = exit => (isForceClose ? undefined : {exit})
111
112 const handleSearchToggle = React.useCallback(() => {
113 setMobileSearchOpen(true)
114 }, [setMobileSearchOpen])
115
116 return (
117 <>
118 {!isMobileSearchOpen && (
119 <Button
120 aria-label="Search"
121 aria-expanded={isMobileSearchOpen}
122 onClick={handleSearchToggle}
123 sx={{
124 '&:focus-visible': {
125 outline: '2px solid',
126 outlineColor: '-webkit-focus-ring-color',
127 outlineOffset: '1px',
128 },
129 }}
130 >
131 <SearchIcon />
132 </Button>
133 )}
134
135 <AnimatePresence>
136 {isMobileSearchOpen ? (
137 <FocusOn returnFocus={true} onEscapeKey={() => resetAndClose(true)}>
138 <Box
139 sx={{
140 position: 'fixed',
141 top: `${HEADER_BAR}px`,
142 left: 0,
143 right: 0,
144 bottom: 0,
145 zIndex: Z_INDEX.SEARCH_OVERLAY,
146 }}
147 >
148 <Box
149 sx={{
150 position: 'absolute',
151 top: 0,
152 left: 0,
153 right: 0,
154 bottom: 0,
155 bg: 'overlay.backdrop',
156 zIndex: -1,
157 }}
158 key="search-backdrop"
159 as={motion.div}
160 initial={{opacity: 0}}
161 animate={{opacity: 1}}
162 transition={{type: 'tween'}}
163 onClick={() => resetAndClose(true)}
164 {...getCloseAnimation({opacity: 0})}
165 />
166 <Box sx={{display: 'flex', flexDirection: 'column', height: resultsOpen ? '100%' : 'auto'}}>
167 <Box
168 sx={{
169 display: 'flex',
170 color: 'fg.default',
171 height: `${HEADER_HEIGHT}px`,
172 flex: '0 0 auto',
173 px: 3,
174 alignItems: 'center',
175 border: '1px solid',
176 borderTopWidth: 0,
177 borderLeftWidth: 0,
178 borderRightWidth: 0,
179 borderColor: 'border.muted',
180 position: 'relative',
181 bg: 'canvas.default',
182 zIndex: Z_INDEX.SEARCH_OVERLAY + 1,
183 }}
184 >
185 <motion.div
186 key="search-box"
187 initial={{scaleX: 0}}
188 animate={{scaleX: 1}}
189 {...getCloseAnimation({scaleX: 0})}
190 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
191 style={{width: '100%', originX: '100%'}}
192 >
193 <Box
194 sx={{
195 position: 'absolute',
196 top: 0,
197 bottom: 0,
198 left: 0,
199 width: '70px',
200 bg: 'canvas.default',
201 zIndex: '-1',
202 }}
203 />
204 <TextInput
205 leadingVisual={SearchIcon}
206 placeholder={`Search ${siteMetadata.title}`}
207 aria-label={`Search ${siteMetadata.title}`}
208 sx={{width: '100%'}}
209 {...getInputProps()}
210 />
211 </motion.div>
212 <Box
213 key="button-blocker"
214 as={motion.div}
215 initial={{opacity: 0}}
216 animate={{opacity: 1}}
217 {...getCloseAnimation({scaleX: 0})}
218 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
219 sx={{
220 position: 'absolute',
221 top: 0,
222 bottom: 0,
223 right: 0,
224 width: '70px',
225 bg: 'canvas.default',
226 zIndex: '-1',
227 }}
228 />
229 <Box
230 key="button"
231 as={motion.div}
232 initial={{opacity: 0}}
233 animate={{opacity: 1}}
234 {...getCloseAnimation({opacity: 0})}
235 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
236 >
237 <Button sx={{ml: 3}} aria-label="Cancel" onClick={() => resetAndClose(false)}>
238 <XIcon />
239 </Button>
240 </Box>
241 </Box>
242 <LightTheme
243 sx={{
244 display: 'flex',
245 bg: 'canvas.default',
246 flexDirection: 'column',
247 flex: '1 1 auto',
248 overflow: 'auto',
249 }}
250 style={{
251 WebkitOverflowScrolling: 'touch',
252 }}
253 {...getMenuProps()}
254 >
255 {resultsOpen ? <SearchResults {...rest} /> : null}
256 </LightTheme>
257 </Box>
258 </Box>
259 </FocusOn>
260 ) : null}
261 </AnimatePresence>
262 </>
263 )
264 }