1 import React from 'react'
2 import {Button, ActionList, 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 import * as styles from './search.module.css'
16
17 const SearchResults = ({results, getItemProps, highlightedIndex}) => {
18 const siteMetadata = useSiteMetadata()
19 if (!results || results.length === 0) {
20 announce('No results')
21 return (
22 <div aria-live="polite" className={styles.Box}>
23 No results
24 </div>
25 )
26 }
27
28 return (
29 <ActionList>
30 {results.map((item, index) => {
31 // keep the variant in the breadcrumb if we have one and its not the
32 // same as the last breadcrumb. this makes sure that variant index pages
33 // don't all appear the same in the search results
34 const variant = getNav.getVariant(getNav.getVariantRoot(item.path), item.path)
35 const hierarchy = getNav.getItemBreadcrumbs(item.path)
36 if (!variant || variant !== hierarchy[hierarchy.length - 1]?.shortName) {
37 hierarchy.pop()
38 }
39
40 return (
41 <ActionList.Item
42 key={item.path}
43 // fixes a bug between downshift and react/primer where the search result item is always disabled
44 // this is safe to remove because we know we never have any disabled search results
45 {...omit(getItemProps({item, index}), 'aria-disabled')}
46 as={LinkNoUnderline}
47 to={item.path}
48 active={highlightedIndex === index}
49 >
50 <div
51 aria-label={`${item.title}${hierarchy.length ? ` in ${hierarchy.map(s => s.shortName || s.title).join(' / ')}` : ` in ${siteMetadata.shortName}`}, ${index + 1} of ${results.length}`}
52 className={styles.Box_1}
53 >
54 <Text className={styles.Text}>
55 {hierarchy.length ? hierarchy.map(s => s.shortName || s.title).join(' / ') : siteMetadata.shortName}
56 </Text>
57 <Text>{item.title}</Text>
58 </div>
59 </ActionList.Item>
60 )
61 })}
62 </ActionList>
63 )
64 }
65
66 export const Desktop = props => {
67 const siteMetadata = useSiteMetadata()
68 const {getInputProps, getMenuProps, resultsOpen, ...rest} = props
69
70 return (
71 <div className={styles.Box_2}>
72 <TextInput
73 placeholder={`Search ${siteMetadata.title}`}
74 aria-label={`Search ${siteMetadata.title}`}
75 className={styles.TextInput}
76 {...getInputProps()}
77 />
78 <div className={styles.Box_3} {...getMenuProps()}>
79 {resultsOpen ? (
80 <LightTheme className={styles.LightTheme}>
81 <SearchResults {...rest} />
82 </LightTheme>
83 ) : null}
84 </div>
85 </div>
86 )
87 }
88
89 export const Mobile = ({
90 resultsOpen,
91 getInputProps,
92 getMenuProps,
93 isMobileSearchOpen,
94 setMobileSearchOpen,
95 isForceClose,
96 resetAndClose,
97 ...rest
98 }) => {
99 const siteMetadata = useSiteMetadata()
100 const getCloseAnimation = exit => (isForceClose ? undefined : {exit})
101
102 const handleSearchToggle = React.useCallback(() => {
103 setMobileSearchOpen(true)
104 }, [setMobileSearchOpen])
105
106 return (
107 <>
108 {!isMobileSearchOpen && (
109 <Button
110 aria-label="Search"
111 aria-expanded={isMobileSearchOpen}
112 onClick={handleSearchToggle}
113 className={styles.Button}
114 >
115 <SearchIcon />
116 </Button>
117 )}
118 <AnimatePresence>
119 {isMobileSearchOpen ? (
120 <FocusOn returnFocus={true} onEscapeKey={() => resetAndClose(true)}>
121 <div
122 style={{
123 top: `${HEADER_BAR}px`,
124 zIndex: Z_INDEX.SEARCH_OVERLAY,
125 }}
126 className={styles.Box_4}
127 >
128 <motion.div
129 key="search-backdrop"
130 initial={{opacity: 0}}
131 animate={{opacity: 1}}
132 transition={{type: 'tween'}}
133 onClick={() => resetAndClose(true)}
134 className={styles.Box_5}
135 {...getCloseAnimation({opacity: 0})}
136 />
137 <div
138 style={{
139 height: resultsOpen ? '100%' : 'auto',
140 }}
141 className={styles.Box_6}
142 >
143 <div
144 style={{
145 height: `${HEADER_HEIGHT}px`,
146 zIndex: Z_INDEX.SEARCH_OVERLAY + 1,
147 }}
148 className={styles.Box_7}
149 >
150 <motion.div
151 key="search-box"
152 initial={{scaleX: 0}}
153 animate={{scaleX: 1}}
154 {...getCloseAnimation({scaleX: 0})}
155 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
156 style={{width: '100%', originX: '100%'}}
157 >
158 <div className={styles.Box_8} />
159 <TextInput
160 leadingVisual={SearchIcon}
161 placeholder={`Search ${siteMetadata.title}`}
162 aria-label={`Search ${siteMetadata.title}`}
163 className={styles.TextInput_1}
164 {...getInputProps()}
165 />
166 </motion.div>
167 <motion.div
168 key="button-blocker"
169 initial={{opacity: 0}}
170 animate={{opacity: 1}}
171 {...getCloseAnimation({scaleX: 0})}
172 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
173 className={styles.Box_9}
174 />
175 <motion.div
176 key="button"
177 initial={{opacity: 0}}
178 animate={{opacity: 1}}
179 {...getCloseAnimation({opacity: 0})}
180 transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
181 >
182 <Button aria-label="Cancel" onClick={() => resetAndClose(false)} className={styles.Button_1}>
183 <XIcon />
184 </Button>
185 </motion.div>
186 </div>
187 <LightTheme
188 style={{
189 WebkitOverflowScrolling: 'touch',
190 }}
191 className={styles.LightTheme_1}
192 {...getMenuProps()}
193 >
194 {resultsOpen ? <SearchResults {...rest} /> : null}
195 </LightTheme>
196 </div>
197 </div>
198 </FocusOn>
199 ) : null}
200 </AnimatePresence>
201 </>
202 )
203 }