Use action item for search results
This uses better selected item styling from primer/react by default. Also make the mobile search skip the exit animation after a navigation.
Luke Karrys committed
Oct 30, 2023 at 17:48 UTC
23fba8d55ccf4e9ce67b75456d09ac9ec7e9a482
5 files changed
+116
-99
src/components/link.js
+1
-10
@@ -1,16 +1,7 @@
1
import React from 'react'
2
import {Link as PrimerLink} from '@primer/react'
3
import {Link as GatsbyLink} from 'gatsby'
4
-
5
-const omit = (obj, ...keys) => {
6
- const res = {}
7
- for (const k of Object.keys(obj)) {
8
- if (!keys.includes(k)) {
9
- res[k] = obj[k]
10
- }
11
- }
12
- return res
13
-}
4
+import omit from '../util/omit'
5
6
const FALLBACK = `http://_${Math.random().toString().slice(2)}._${Math.random().toString().slice(2)}`
7
src/components/search-results.js
deleted
-56
@@ -1,56 +0,0 @@
1
-import React from 'react'
2
-import {Box, Text} from '@primer/react'
3
-import {LinkNoUnderline} from './link'
4
-import useSiteMetadata from '../hooks/use-site-metadata'
5
-import * as getNav from '../util/get-nav'
6
-
7
-const SearchItem = ({item}) => {
8
- const siteMetadata = useSiteMetadata()
9
- const variant = getNav.getVariant(getNav.getVariantRoot(item.path), item.path)
10
- const hierarchy = getNav.getItemBreadcrumbs(item.path)
11
-
12
- // keep the variant in the breadcrumb if we have one and its not the
13
- // same as the last breadcrumb. this makes sure that variant index pages
14
- // don't all appear the same in the search results
15
- if (!variant || variant !== hierarchy[hierarchy.length - 1].shortName) {
16
- hierarchy.pop()
17
- }
18
-
19
- return (
20
- <>
21
- <Text sx={{fontSize: 0}}>
22
- {hierarchy.length ? hierarchy.map(s => s.shortName || s.title).join(' / ') : siteMetadata.shortName}
23
- </Text>
24
- {item.title}
25
- </>
26
- )
27
-}
28
-
29
-const Result = React.forwardRef(function Result({sx, ...props}, ref) {
30
- return <Box ref={ref} sx={{px: 3, py: 2, color: 'fg.default', fontSize: 1, ...sx}} {...props} />
31
-})
32
-
33
-function SearchResults({results, getItemProps, highlightedIndex}) {
34
- if (!results || results.length === 0) {
35
- return <Result>No results</Result>
36
- }
37
-
38
- return results.map((item, index) => (
39
- <Result
40
- {...getItemProps({item, index})}
41
- as={LinkNoUnderline}
42
- to={item.path}
43
- key={item.path}
44
- sx={{
45
- display: 'flex',
46
- flexDirection: 'column',
47
- flex: '0 0 auto',
48
- bg: highlightedIndex === index ? 'neutral.muted' : 'transparent',
49
- }}
50
- >
51
- <SearchItem item={item} />
52
- </Result>
53
- ))
54
-}
55
-
56
-export default SearchResults
src/components/search.js
+60
-26
@@ -1,17 +1,61 @@
1
import React from 'react'
2
-import {Button, Box} from '@primer/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 SearchResults from './search-results'
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()
14
- const {getInputProps, getMenuProps, resultsOpen, results, getItemProps, highlightedIndex} = props
58
+ const {getInputProps, getMenuProps, resultsOpen, ...rest} = props
59
60
return (
61
<Box sx={{position: 'relative'}}>
@@ -36,7 +80,7 @@ export const Desktop = props => {
80
borderStyle: 'solid',
81
}}
82
>
39
- <SearchResults {...{results, getItemProps, highlightedIndex}} />
83
+ <SearchResults {...rest} />
84
</LightTheme>
85
) : null}
86
</Box>
@@ -45,26 +89,17 @@ export const Desktop = props => {
89
}
90
91
export const Mobile = ({
48
- results,
92
resultsOpen,
93
getInputProps,
51
- getItemProps,
94
getMenuProps,
53
- highlightedIndex,
95
isMobileSearchOpen,
96
setMobileSearchOpen,
97
+ isForceClose,
98
resetAndClose,
99
+ ...rest
100
}) => {
101
const siteMetadata = useSiteMetadata()
59
-
60
- // Fixes focus behavior on iOS where the input gets focus styles but not the
61
- // actual focus after animating open.
62
- const ref = React.useRef()
63
- React.useEffect(() => {
64
- if (isMobileSearchOpen) {
65
- ref.current.focus()
66
- }
67
- }, [ref, isMobileSearchOpen])
102
+ const getCloseAnimation = exit => (isForceClose ? undefined : {exit})
103
104
return (
105
<>
@@ -73,7 +108,7 @@ export const Mobile = ({
108
</Button>
109
<AnimatePresence>
110
{isMobileSearchOpen ? (
76
- <FocusOn returnFocus={true} onEscapeKey={resetAndClose}>
111
+ <FocusOn returnFocus={true} onEscapeKey={() => resetAndClose(true)}>
112
<Box
113
sx={{
114
position: 'fixed',
@@ -98,9 +133,9 @@ export const Mobile = ({
133
as={motion.div}
134
initial={{opacity: 0}}
135
animate={{opacity: 1}}
101
- exit={{opacity: 0}}
136
transition={{type: 'tween'}}
103
- onClick={resetAndClose}
137
+ onClick={() => resetAndClose(true)}
138
+ {...getCloseAnimation({opacity: 0})}
139
/>
140
<Box sx={{display: 'flex', flexDirection: 'column', height: resultsOpen ? '100%' : 'auto'}}>
141
<Box
@@ -123,7 +158,7 @@ export const Mobile = ({
158
key="search-box"
159
initial={{scaleX: 0}}
160
animate={{scaleX: 1}}
126
- exit={{scaleX: 0}}
161
+ {...getCloseAnimation({scaleX: 0})}
162
transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
163
style={{width: '100%', originX: '100%'}}
164
>
@@ -143,7 +178,7 @@ export const Mobile = ({
178
placeholder={`Search ${siteMetadata.title}`}
179
aria-label={`Search ${siteMetadata.title}`}
180
sx={{width: '100%'}}
146
- {...getInputProps({ref})}
181
+ {...getInputProps()}
182
/>
183
</motion.div>
184
<Box
@@ -151,7 +186,7 @@ export const Mobile = ({
186
as={motion.div}
187
initial={{opacity: 0}}
188
animate={{opacity: 1}}
154
- exit={{opacity: 0}}
189
+ {...getCloseAnimation({scaleX: 0})}
190
transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
191
sx={{
192
position: 'absolute',
@@ -168,10 +203,10 @@ export const Mobile = ({
203
as={motion.div}
204
initial={{opacity: 0}}
205
animate={{opacity: 1}}
171
- exit={{opacity: 0}}
206
+ {...getCloseAnimation({opacity: 0})}
207
transition={{type: 'tween', ease: 'easeOut', duration: 0.2}}
208
>
174
- <Button sx={{ml: 3}} aria-label="Cancel" onClick={resetAndClose}>
209
+ <Button sx={{ml: 3}} aria-label="Cancel" onClick={() => resetAndClose(false)}>
210
<XIcon />
211
</Button>
212
</Box>
@@ -180,7 +215,6 @@ export const Mobile = ({
215
sx={{
216
display: 'flex',
217
bg: 'canvas.default',
183
- py: resultsOpen ? 1 : 0,
218
flexDirection: 'column',
219
flex: '1 1 auto',
220
overflow: 'auto',
@@ -190,7 +224,7 @@ export const Mobile = ({
224
}}
225
{...getMenuProps()}
226
>
193
- {resultsOpen ? <SearchResults {...{results, getItemProps, highlightedIndex}} /> : null}
227
+ {resultsOpen ? <SearchResults {...rest} /> : null}
228
</LightTheme>
229
</Box>
230
</Box>
src/hooks/use-search.js
+44
-7
@@ -55,17 +55,16 @@ const useCliVersion = () => {
55
56
const useSearchCombobox = (results, setQuery) => {
57
const isMobile = useIsMobile()
58
- const [isMobileSearchOpen, setMobileSearchOpen] = React.useState(false)
58
59
const combobox = useCombobox({
60
id: 'search-box',
61
items: results || [],
62
+ selectedItem: null,
63
onInputValueChange: ({inputValue}) => setQuery(inputValue),
64
onSelectedItemChange: ({selectedItem}) => {
65
if (selectedItem) {
66
navigate(selectedItem.path)
67
- setMobileSearchOpen(false)
68
- combobox.reset()
67
+ resetAndClose(true)
68
}
69
},
70
itemToString: item => (item ? item.title : ''),
@@ -94,16 +93,54 @@ const useSearchCombobox = (results, setQuery) => {
93
},
94
})
95
97
- const resetAndClose = React.useCallback(() => {
98
- combobox.reset()
99
- setMobileSearchOpen(false)
100
- }, [combobox, setMobileSearchOpen])
96
+ const [isMobileSearchOpen, setMobileSearchOpen] = React.useState(false)
97
+ const [isForceClose, setForceClose] = React.useState(false)
98
+ const forceCloseRef = React.useRef(false)
99
+
100
+ const resetAndClose = React.useCallback(
101
+ force => {
102
+ combobox.reset()
103
+ if (force === true) {
104
+ setForceClose(true)
105
+ } else {
106
+ setMobileSearchOpen(false)
107
+ }
108
+ },
109
+ [combobox, setMobileSearchOpen],
110
+ )
111
+
112
+ // if forceClose is set then we wait until the exit animation props have
113
+ // been removed in the component and then set mobile search to false
114
+ React.useEffect(() => {
115
+ if (isMobileSearchOpen && isForceClose && !forceCloseRef.current) {
116
+ setMobileSearchOpen(false)
117
+ }
118
+ forceCloseRef.current = isForceClose
119
+ }, [forceCloseRef, isForceClose, isMobileSearchOpen, setMobileSearchOpen])
120
+
121
+ // always reset force close any time mobile search is closed
122
+ React.useEffect(() => {
123
+ if (!isMobileSearchOpen) {
124
+ setForceClose(false)
125
+ }
126
+ }, [setForceClose, isMobileSearchOpen])
127
+
128
+ // Fixes focus behavior on iOS where the input gets focus styles but not the
129
+ // actual focus after animating open.
130
+ const inputRef = React.useRef()
131
+ React.useEffect(() => {
132
+ if (isMobileSearchOpen) {
133
+ inputRef.current.focus()
134
+ }
135
+ }, [inputRef, isMobileSearchOpen])
136
137
return {
138
...combobox,
139
isMobileSearchOpen,
140
+ isForceClose,
141
setMobileSearchOpen,
142
resetAndClose,
143
+ getInputProps: (...props) => combobox.getInputProps({ref: inputRef, ...props}),
144
}
145
}
146
src/util/omit.js
new
+11
@@ -0,0 +1,11 @@
1
+const omit = (obj, ...keys) => {
2
+ const res = {}
3
+ for (const k of Object.keys(obj)) {
4
+ if (!keys.includes(k)) {
5
+ res[k] = obj[k]
6
+ }
7
+ }
8
+ return res
9
+}
10
+
11
+export default omit