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