@graphcommerce/magento-search-overlay 9.0.0-canary.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/components/SearchOverlay.tsx +58 -0
- package/components/SearchOverlayBodyBase.tsx +41 -0
- package/components/SearchOverlayCategories.tsx +97 -0
- package/components/SearchOverlayFab.tsx +21 -0
- package/components/SearchOverlayHeader.tsx +65 -0
- package/components/SearchOverlayInput.tsx +111 -0
- package/components/SearchOverlayItem.tsx +49 -0
- package/components/SearchOverlayLoader.tsx +27 -0
- package/components/SearchOverlayProducts.tsx +109 -0
- package/components/SearchOverlayProvider.tsx +210 -0
- package/components/SearchOverlayRecent.tsx +16 -0
- package/components/SearchOverlaySuggestions.tsx +66 -0
- package/components/SearchPlaceholder.tsx +24 -0
- package/hooks/useOpenWithShortKey.ts +27 -0
- package/hooks/useQuicksearch.ts +55 -0
- package/hooks/useRecentSearches.ts +24 -0
- package/hooks/useStickyHeaderOnScroll.ts +28 -0
- package/index.ts +1 -0
- package/next-env.d.ts +4 -0
- package/package.json +36 -0
- package/plugins/SearchOverlayFabPlugin.tsx +18 -0
- package/plugins/SearchOverlayFieldPlugin.tsx +32 -0
- package/tsconfig.json +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @graphcommerce/magento-search-overlay
|
|
2
|
+
|
|
3
|
+
## 9.0.0-canary.117
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#2361](https://github.com/graphcommerce-org/graphcommerce/pull/2361) [`9c3149c`](https://github.com/graphcommerce-org/graphcommerce/commit/9c3149cb7550c6bf7de4b8e3bcaabe2f6a70d5c7) - Search overlay package this is compatible with Magento's default search as well as any other implementation like Algolia and Adobe Sensei. ([@paales](https://github.com/paales))
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useMotionValueValue } from '@graphcommerce/framer-utils'
|
|
2
|
+
import type { ProductListItemRenderer } from '@graphcommerce/magento-product'
|
|
3
|
+
import { MediaQuery, Overlay } from '@graphcommerce/next-ui'
|
|
4
|
+
import { Box, useTheme } from '@mui/material'
|
|
5
|
+
import { SearchOverlayBodyBase } from './SearchOverlayBodyBase'
|
|
6
|
+
import { SearchOverlayCategories } from './SearchOverlayCategories'
|
|
7
|
+
import { SearchOverlayHeader } from './SearchOverlayHeader'
|
|
8
|
+
import { SearchOverlayProducts } from './SearchOverlayProducts'
|
|
9
|
+
import { searchOverlayIsOpen, SearchOverlayProvider } from './SearchOverlayProvider'
|
|
10
|
+
import { SearchOverlaySuggestions } from './SearchOverlaySuggestions'
|
|
11
|
+
|
|
12
|
+
export type SearchOverlayProps = {
|
|
13
|
+
productListRenderer: ProductListItemRenderer
|
|
14
|
+
slotProps?: {
|
|
15
|
+
overlay?: Partial<React.ComponentProps<typeof Overlay>>
|
|
16
|
+
header?: React.ComponentProps<typeof SearchOverlayHeader>
|
|
17
|
+
body?: Partial<React.ComponentProps<typeof SearchOverlayBodyBase>>
|
|
18
|
+
suggestions?: Partial<React.ComponentProps<typeof SearchOverlaySuggestions>>
|
|
19
|
+
categories?: Partial<React.ComponentProps<typeof SearchOverlayCategories>>
|
|
20
|
+
products?: Partial<React.ComponentProps<typeof SearchOverlayProducts>>
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SearchOverlay(props: SearchOverlayProps) {
|
|
25
|
+
const { productListRenderer, slotProps } = props
|
|
26
|
+
const open = useMotionValueValue(searchOverlayIsOpen, (v) => v)
|
|
27
|
+
const theme = useTheme()
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Overlay
|
|
31
|
+
active={open}
|
|
32
|
+
onClosed={() => searchOverlayIsOpen.set(false)}
|
|
33
|
+
variantMd='top'
|
|
34
|
+
variantSm='bottom'
|
|
35
|
+
sizeMd='floating'
|
|
36
|
+
sizeSm='full'
|
|
37
|
+
justifyMd='center'
|
|
38
|
+
disableAnimation
|
|
39
|
+
disableDrag
|
|
40
|
+
widthMd={`min(${theme.breakpoints.values.lg}px, 100vw - ${theme.page.horizontal} * 2)`}
|
|
41
|
+
bgColor='paper'
|
|
42
|
+
className='SearchOverlay-root'
|
|
43
|
+
{...slotProps?.overlay}
|
|
44
|
+
>
|
|
45
|
+
<SearchOverlayProvider open={open}>
|
|
46
|
+
<SearchOverlayHeader {...slotProps?.header} />
|
|
47
|
+
<SearchOverlayBodyBase {...slotProps?.body}>
|
|
48
|
+
<SearchOverlaySuggestions {...slotProps?.suggestions} />
|
|
49
|
+
<SearchOverlayCategories {...slotProps?.categories} />
|
|
50
|
+
<SearchOverlayProducts
|
|
51
|
+
productListRenderer={productListRenderer}
|
|
52
|
+
{...slotProps?.products}
|
|
53
|
+
/>
|
|
54
|
+
</SearchOverlayBodyBase>
|
|
55
|
+
</SearchOverlayProvider>
|
|
56
|
+
</Overlay>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { styled } from '@mui/material'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
type StyledDivProps = {
|
|
5
|
+
keyboardOpen?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const StyledDiv = styled('div', {
|
|
9
|
+
name: 'SearchOverlayBodyBase',
|
|
10
|
+
})<StyledDivProps>(({ theme, keyboardOpen }) => ({
|
|
11
|
+
padding: `0 ${theme.page.horizontal} ${theme.page.vertical}`,
|
|
12
|
+
'&:empty': { display: 'none' },
|
|
13
|
+
'.SearchOverlay-root.scrolled &': {
|
|
14
|
+
paddingTop: theme.appShell.headerHeightSm,
|
|
15
|
+
},
|
|
16
|
+
...(keyboardOpen && {
|
|
17
|
+
paddingBottom: '40vh', // It would be preferable to use env(keyboard-inset-height, 0px) here, but it is not fully supported in iOS yet.
|
|
18
|
+
}),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
export function SearchOverlayBodyBase(props) {
|
|
22
|
+
const [keyboardOpen, setKeyboardOpen] = useState(false)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const handleResize = () => {
|
|
26
|
+
const isKeyboardOpen =
|
|
27
|
+
(globalThis.visualViewport &&
|
|
28
|
+
globalThis.visualViewport?.height < globalThis.innerHeight * 0.8) ??
|
|
29
|
+
false
|
|
30
|
+
setKeyboardOpen(isKeyboardOpen)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
globalThis.visualViewport?.addEventListener('resize', handleResize)
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
globalThis.visualViewport?.removeEventListener('resize', handleResize)
|
|
37
|
+
}
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
return <StyledDiv {...props} keyboardOpen={keyboardOpen} />
|
|
41
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useQuery } from '@graphcommerce/graphql'
|
|
2
|
+
import { productListLink } from '@graphcommerce/magento-product'
|
|
3
|
+
import { CategorySearchDocument } from '@graphcommerce/magento-search'
|
|
4
|
+
import type { CategorySearchResultFragment } from '@graphcommerce/magento-search'
|
|
5
|
+
import type { SectionContainerProps } from '@graphcommerce/next-ui'
|
|
6
|
+
import { filterNonNullableKeys, NextLink, SectionContainer } from '@graphcommerce/next-ui'
|
|
7
|
+
import { Trans } from '@lingui/macro'
|
|
8
|
+
import type {
|
|
9
|
+
BreadcrumbsProps,
|
|
10
|
+
LinkProps,
|
|
11
|
+
ListItemButtonProps,
|
|
12
|
+
TypographyProps,
|
|
13
|
+
} from '@mui/material'
|
|
14
|
+
import { Breadcrumbs, Typography } from '@mui/material'
|
|
15
|
+
import { forwardRef } from 'react'
|
|
16
|
+
import { SearchOverlayItem } from './SearchOverlayItem'
|
|
17
|
+
import { useSearchOverlay } from './SearchOverlayProvider'
|
|
18
|
+
|
|
19
|
+
type SearchOverlayCategoriesProps = {
|
|
20
|
+
slotProps?: {
|
|
21
|
+
link?: Omit<LinkProps, 'href'>
|
|
22
|
+
breadcrumbs?: BreadcrumbsProps
|
|
23
|
+
typography?: TypographyProps
|
|
24
|
+
sectionContainer?: SectionContainerProps
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SearchOverlayCategoryProps = {
|
|
29
|
+
category: CategorySearchResultFragment
|
|
30
|
+
slotProps?: {
|
|
31
|
+
listItemButton?: Omit<ListItemButtonProps<'a'>, 'href'>
|
|
32
|
+
breadcrumbs?: BreadcrumbsProps
|
|
33
|
+
typography?: TypographyProps
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SearchOverlayCategory = forwardRef<HTMLAnchorElement, SearchOverlayCategoryProps>(
|
|
38
|
+
(props, ref) => {
|
|
39
|
+
const { category, slotProps } = props
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<SearchOverlayItem
|
|
43
|
+
component={NextLink}
|
|
44
|
+
ref={ref}
|
|
45
|
+
href={productListLink({
|
|
46
|
+
filters: { category_uid: { eq: category.uid } },
|
|
47
|
+
sort: {},
|
|
48
|
+
url: category.url_path ?? '',
|
|
49
|
+
})}
|
|
50
|
+
{...slotProps?.listItemButton}
|
|
51
|
+
>
|
|
52
|
+
<Breadcrumbs {...slotProps?.breadcrumbs}>
|
|
53
|
+
{filterNonNullableKeys(category.breadcrumbs, ['category_name']).map((breadcrumb) => (
|
|
54
|
+
<Typography
|
|
55
|
+
color='text.primary'
|
|
56
|
+
key={breadcrumb.category_name}
|
|
57
|
+
{...slotProps?.typography}
|
|
58
|
+
>
|
|
59
|
+
{breadcrumb.category_name}
|
|
60
|
+
</Typography>
|
|
61
|
+
))}
|
|
62
|
+
<Typography color='text.primary' {...slotProps?.typography}>
|
|
63
|
+
{category.name}
|
|
64
|
+
</Typography>
|
|
65
|
+
</Breadcrumbs>
|
|
66
|
+
</SearchOverlayItem>
|
|
67
|
+
)
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
SearchOverlayCategory.displayName = 'SearchOverlayCategory'
|
|
72
|
+
|
|
73
|
+
export function SearchOverlayCategories(props: SearchOverlayCategoriesProps) {
|
|
74
|
+
const { params } = useSearchOverlay()
|
|
75
|
+
const { search } = params
|
|
76
|
+
|
|
77
|
+
const { slotProps = {} } = props
|
|
78
|
+
const { sectionContainer: sectionContainerProps = {} } = slotProps
|
|
79
|
+
|
|
80
|
+
const categories = useQuery(CategorySearchDocument, {
|
|
81
|
+
variables: { search, pageSize: 5 },
|
|
82
|
+
skip: !search || search.length < 3,
|
|
83
|
+
})
|
|
84
|
+
const categoryItems = filterNonNullableKeys(
|
|
85
|
+
categories.data?.categories?.items ?? categories.previousData?.categories?.items,
|
|
86
|
+
).filter((c) => c.include_in_menu)
|
|
87
|
+
|
|
88
|
+
if (categories.error || categoryItems.length === 0 || !search) return null
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<SectionContainer labelLeft={<Trans>Categories</Trans>} {...sectionContainerProps}>
|
|
92
|
+
{categoryItems.map((category) => (
|
|
93
|
+
<SearchOverlayCategory key={category.uid} category={category} slotProps={slotProps} />
|
|
94
|
+
))}
|
|
95
|
+
</SectionContainer>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { iconSearch, IconSvg } from '@graphcommerce/next-ui'
|
|
2
|
+
import { Fab } from '@mui/material'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { searchOverlayIsOpen } from './SearchOverlayProvider'
|
|
5
|
+
|
|
6
|
+
export function SearchOverlayFab() {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
globalThis.document.body.querySelector<HTMLInputElement>('[name="search"]')?.focus()
|
|
9
|
+
}, [])
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Fab
|
|
13
|
+
onClick={() => searchOverlayIsOpen.set(true)}
|
|
14
|
+
color='inherit'
|
|
15
|
+
size='medium'
|
|
16
|
+
sx={{ position: 'absolute', right: 0, top: 0 }}
|
|
17
|
+
>
|
|
18
|
+
<IconSvg src={iconSearch} size='large' />
|
|
19
|
+
</Fab>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { breakpointVal } from '@graphcommerce/next-ui'
|
|
2
|
+
import { LayoutHeaderClose } from '@graphcommerce/next-ui/Layout/components/LayoutHeaderClose'
|
|
3
|
+
import { Box, styled } from '@mui/material'
|
|
4
|
+
import { useStickyHeaderOnScroll } from '../hooks/useStickyHeaderOnScroll'
|
|
5
|
+
import { SearchInput } from './SearchOverlayInput'
|
|
6
|
+
import { searchOverlayIsOpen, useSearchOverlay } from './SearchOverlayProvider'
|
|
7
|
+
|
|
8
|
+
const SearchOverlayHeaderRoot = styled(Box, { name: 'SearchOverlayHeader', slot: 'Root' })(
|
|
9
|
+
({ theme }) => ({
|
|
10
|
+
display: 'grid',
|
|
11
|
+
top: 0,
|
|
12
|
+
zIndex: theme.zIndex.appBar,
|
|
13
|
+
background: theme.palette.background.paper,
|
|
14
|
+
boxShadow: theme.shadows[1],
|
|
15
|
+
height: theme.appShell.headerHeightSm,
|
|
16
|
+
gap: theme.page.horizontal,
|
|
17
|
+
alignItems: 'center',
|
|
18
|
+
gridTemplateColumns: '1fr auto auto',
|
|
19
|
+
[theme.breakpoints.up('md')]: {
|
|
20
|
+
height: theme.appShell.appBarHeightMd,
|
|
21
|
+
},
|
|
22
|
+
'.SearchOverlay-root.scrolled &': { position: 'fixed', width: '100%' },
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
type SearchOverlayHeaderProps = React.ComponentProps<typeof SearchOverlayHeaderRoot> & {
|
|
27
|
+
slotProps?: {
|
|
28
|
+
input?: Omit<React.ComponentProps<typeof SearchInput>, 'params'>
|
|
29
|
+
close?: React.ComponentProps<typeof LayoutHeaderClose>
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function SearchOverlayHeader(props: SearchOverlayHeaderProps) {
|
|
34
|
+
useStickyHeaderOnScroll()
|
|
35
|
+
const { params } = useSearchOverlay()
|
|
36
|
+
const { slotProps, ...rest } = props
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<SearchOverlayHeaderRoot {...rest}>
|
|
40
|
+
<SearchInput
|
|
41
|
+
inputSx={{ typography: 'h4', p: 0 }}
|
|
42
|
+
autoFocus
|
|
43
|
+
params={params}
|
|
44
|
+
size='medium'
|
|
45
|
+
{...slotProps?.input}
|
|
46
|
+
sx={[
|
|
47
|
+
(theme) => ({
|
|
48
|
+
width: '100%',
|
|
49
|
+
height: '100%',
|
|
50
|
+
typography: 'h4',
|
|
51
|
+
px: theme.page.horizontal,
|
|
52
|
+
...breakpointVal(
|
|
53
|
+
'borderRadius',
|
|
54
|
+
theme.shape.borderRadius * 3,
|
|
55
|
+
theme.shape.borderRadius * 4,
|
|
56
|
+
theme.breakpoints.values,
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
...(Array.isArray(slotProps?.input?.sx) ? slotProps.input.sx : [slotProps?.input?.sx]),
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
62
|
+
<LayoutHeaderClose onClose={() => searchOverlayIsOpen.set(false)} {...slotProps?.close} />
|
|
63
|
+
</SearchOverlayHeaderRoot>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { InputBaseElement } from '@graphcommerce/ecommerce-ui'
|
|
2
|
+
import type { ProductListParams } from '@graphcommerce/magento-product'
|
|
3
|
+
import { useProductFiltersPro } from '@graphcommerce/magento-product'
|
|
4
|
+
import { useSearchResultRemaining } from '@graphcommerce/magento-search'
|
|
5
|
+
import { FormAutoSubmit, useDebounce } from '@graphcommerce/react-hook-form'
|
|
6
|
+
import { t } from '@lingui/macro'
|
|
7
|
+
import type { BoxProps, InputBaseProps, SxProps, Theme } from '@mui/material'
|
|
8
|
+
import { Box } from '@mui/material'
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { useRecentSearches } from '../hooks/useRecentSearches'
|
|
11
|
+
import { useSearchInput } from './SearchOverlayProvider'
|
|
12
|
+
|
|
13
|
+
function SearchInputShadow(
|
|
14
|
+
props: BoxProps<'div'> & { params: ProductListParams; inputSx?: SxProps<Theme> },
|
|
15
|
+
) {
|
|
16
|
+
const { params, sx, inputSx, ...rest } = props
|
|
17
|
+
const { remaining, resultSearch, targetSearch } = useSearchResultRemaining(params)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Box
|
|
21
|
+
component='div'
|
|
22
|
+
sx={[
|
|
23
|
+
{ display: 'flex', height: '100%', alignItems: 'center' },
|
|
24
|
+
...(Array.isArray(sx) ? sx : [sx]),
|
|
25
|
+
]}
|
|
26
|
+
{...rest}
|
|
27
|
+
>
|
|
28
|
+
{!resultSearch && !targetSearch ? (
|
|
29
|
+
<Box component='span'>{resultSearch}</Box>
|
|
30
|
+
) : (
|
|
31
|
+
<>
|
|
32
|
+
<Box
|
|
33
|
+
component='span'
|
|
34
|
+
sx={[{ color: 'transparent' }, ...(Array.isArray(inputSx) ? inputSx : [inputSx])]}
|
|
35
|
+
>
|
|
36
|
+
{resultSearch}
|
|
37
|
+
</Box>
|
|
38
|
+
<Box
|
|
39
|
+
component='span'
|
|
40
|
+
sx={[
|
|
41
|
+
{
|
|
42
|
+
typography: 'h4',
|
|
43
|
+
color: 'transparent',
|
|
44
|
+
borderBottom: '2px solid',
|
|
45
|
+
borderImage: 'linear-gradient(108deg,#0894FF,#C959DD 34%,#FF2E54 68%,#FF9004)',
|
|
46
|
+
borderImageSlice: 1,
|
|
47
|
+
},
|
|
48
|
+
...(Array.isArray(inputSx) ? inputSx : [inputSx]),
|
|
49
|
+
]}
|
|
50
|
+
>
|
|
51
|
+
{remaining}
|
|
52
|
+
</Box>
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
</Box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SearchInputProps = Omit<InputBaseProps, 'name' | 'defaultValue' | 'ref'> & {
|
|
60
|
+
params: ProductListParams
|
|
61
|
+
inputSx?: SxProps<Theme>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
|
65
|
+
(props, rootRef) => {
|
|
66
|
+
const { params, sx, inputSx, inputRef, ...rest } = props
|
|
67
|
+
const { form, submit } = useProductFiltersPro()
|
|
68
|
+
|
|
69
|
+
const { getRootProps } = useSearchInput({ rootRef })
|
|
70
|
+
const { ref, selected, ...rootProps } = getRootProps()
|
|
71
|
+
const { updateRecentSearches } = useRecentSearches()
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Box sx={{ display: 'grid', '& > *': { gridArea: '1 / 1' }, height: '100%' }}>
|
|
75
|
+
<InputBaseElement
|
|
76
|
+
control={form.control}
|
|
77
|
+
name='search'
|
|
78
|
+
color='primary'
|
|
79
|
+
size='medium'
|
|
80
|
+
fullWidth
|
|
81
|
+
autoFocus
|
|
82
|
+
placeholder={t`Search...`}
|
|
83
|
+
type='text'
|
|
84
|
+
spellCheck='false'
|
|
85
|
+
autoComplete='off'
|
|
86
|
+
sx={[
|
|
87
|
+
(theme) => ({
|
|
88
|
+
'& .MuiInputBase-input': { ...inputSx },
|
|
89
|
+
...(selected && {
|
|
90
|
+
boxShadow: `inset 0 0 0 2px ${theme.palette.primary.main}`,
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
...(Array.isArray(sx) ? sx : [sx]),
|
|
94
|
+
]}
|
|
95
|
+
{...rest}
|
|
96
|
+
{...rootProps}
|
|
97
|
+
inputRef={ref}
|
|
98
|
+
onKeyUp={useDebounce(() => {
|
|
99
|
+
updateRecentSearches(form.getValues('search') ?? '')
|
|
100
|
+
}, 1000)}
|
|
101
|
+
/>
|
|
102
|
+
<SearchInputShadow params={params} sx={sx} inputSx={inputSx} />
|
|
103
|
+
<FormAutoSubmit control={form.control} name={['search']} submit={submit} leading />
|
|
104
|
+
</Box>
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
110
|
+
SearchInput.displayName = 'SearchInput'
|
|
111
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ListItemButtonProps } from '@mui/material'
|
|
2
|
+
import { alpha, ListItemButton } from '@mui/material'
|
|
3
|
+
import type { ElementType } from 'react'
|
|
4
|
+
import { forwardRef, memo } from 'react'
|
|
5
|
+
import { useSearchItem } from './SearchOverlayProvider'
|
|
6
|
+
|
|
7
|
+
export type SearchOverlayItemProps<C extends ElementType = 'li'> = ListItemButtonProps<
|
|
8
|
+
C,
|
|
9
|
+
{ component?: C }
|
|
10
|
+
>
|
|
11
|
+
|
|
12
|
+
export const SearchOverlayItem = memo(
|
|
13
|
+
forwardRef(
|
|
14
|
+
<C extends ElementType = 'li'>(
|
|
15
|
+
props: React.PropsWithoutRef<SearchOverlayItemProps<C>>,
|
|
16
|
+
rootRef: React.Ref<C extends 'li' ? HTMLLIElement : HTMLAnchorElement>,
|
|
17
|
+
) => {
|
|
18
|
+
const { sx, component, ...rest } = props
|
|
19
|
+
|
|
20
|
+
const { getRootProps } = useSearchItem({ rootRef })
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ListItemButton
|
|
24
|
+
{...getRootProps()}
|
|
25
|
+
component={component}
|
|
26
|
+
sx={[
|
|
27
|
+
(theme) => ({
|
|
28
|
+
px: theme.page.horizontal,
|
|
29
|
+
mx: `calc(${theme.page.horizontal} * -1)`,
|
|
30
|
+
'&.Mui-selected': {
|
|
31
|
+
boxShadow: `inset 0 0 0 2px ${theme.palette.primary.main}`,
|
|
32
|
+
backgroundColor: alpha(
|
|
33
|
+
theme.palette.background.paper,
|
|
34
|
+
theme.palette.action.selectedOpacity,
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
...(Array.isArray(sx) ? sx : [sx]),
|
|
39
|
+
]}
|
|
40
|
+
{...rest}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
) as <C extends ElementType = 'li'>(
|
|
46
|
+
props: SearchOverlayItemProps<C> & {
|
|
47
|
+
ref?: React.Ref<C extends 'li' ? HTMLLIElement : HTMLAnchorElement>
|
|
48
|
+
},
|
|
49
|
+
) => JSX.Element
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useMotionSelector } from '@graphcommerce/framer-utils'
|
|
2
|
+
import { SearchFab } from '@graphcommerce/magento-search/components/SearchFab/SearchFab.interceptor'
|
|
3
|
+
import { motionValue } from 'framer-motion'
|
|
4
|
+
import dynamic from 'next/dynamic'
|
|
5
|
+
import type { SearchOverlayProps } from './SearchOverlay'
|
|
6
|
+
import { searchOverlayIsOpen } from './SearchOverlayProvider'
|
|
7
|
+
|
|
8
|
+
export const searchOverlayLoading = motionValue(false)
|
|
9
|
+
|
|
10
|
+
const loaded = motionValue(false)
|
|
11
|
+
|
|
12
|
+
const SearchOverlayDynamic = dynamic(
|
|
13
|
+
async () => {
|
|
14
|
+
searchOverlayLoading.set(true)
|
|
15
|
+
const { SearchOverlay } = await import('./SearchOverlay')
|
|
16
|
+
searchOverlayLoading.set(false)
|
|
17
|
+
loaded.set(true)
|
|
18
|
+
return SearchOverlay
|
|
19
|
+
},
|
|
20
|
+
{ ssr: false },
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
export function SearchOverlayLoader(props: SearchOverlayProps) {
|
|
24
|
+
const openOrLoaded = useMotionSelector([loaded, searchOverlayIsOpen], (v) => v[0] || v[1])
|
|
25
|
+
if (!openOrLoaded) return null
|
|
26
|
+
return <SearchOverlayDynamic {...props} />
|
|
27
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ProductListItemRenderer } from '@graphcommerce/magento-product'
|
|
2
|
+
import { ProductListItemsBase, productListLink } from '@graphcommerce/magento-product'
|
|
3
|
+
import { SectionContainer } from '@graphcommerce/next-ui'
|
|
4
|
+
import { Trans } from '@lingui/macro'
|
|
5
|
+
import type { SxProps, Theme } from '@mui/material'
|
|
6
|
+
import { Link } from '@mui/material'
|
|
7
|
+
import { useRouter } from 'next/router'
|
|
8
|
+
import type { ComponentProps } from 'react'
|
|
9
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
10
|
+
import type { Entries } from 'type-fest'
|
|
11
|
+
import { searchOverlayIsOpen, useSearchItem, useSearchOverlay } from './SearchOverlayProvider'
|
|
12
|
+
import { SearchPlaceholder } from './SearchPlaceholder'
|
|
13
|
+
|
|
14
|
+
type SearchOverlayProductsProps = {
|
|
15
|
+
productListRenderer: ProductListItemRenderer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function withUseMenu<T extends keyof ProductListItemRenderer>(
|
|
19
|
+
renderer: ProductListItemRenderer[T],
|
|
20
|
+
) {
|
|
21
|
+
return (props: ComponentProps<ProductListItemRenderer[T]>) => {
|
|
22
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
const { getRootProps } = useSearchItem({ rootRef })
|
|
24
|
+
|
|
25
|
+
const Component = renderer as React.ComponentType<
|
|
26
|
+
ComponentProps<ProductListItemRenderer[keyof ProductListItemRenderer]>
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
const root = getRootProps()
|
|
30
|
+
const sx: SxProps<Theme> = [
|
|
31
|
+
root.selected &&
|
|
32
|
+
((theme) => ({
|
|
33
|
+
outline: `2px solid ${theme.palette.primary.main}`,
|
|
34
|
+
outlineOffset: '7px',
|
|
35
|
+
})),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
return <Component {...props} slotProps={{ root: { ...root, sx } }} />
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function useRendererWithMenu(productListRenderer: ProductListItemRenderer) {
|
|
43
|
+
return useMemo(() => {
|
|
44
|
+
const entries = Object.entries(productListRenderer) as Entries<typeof productListRenderer>
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
entries.map(([key, renderer]) => [key, withUseMenu(renderer)]),
|
|
47
|
+
) as typeof productListRenderer
|
|
48
|
+
}, [productListRenderer])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SearchOverlayProducts({ productListRenderer }: SearchOverlayProductsProps) {
|
|
52
|
+
const rendererWithMenu = useRendererWithMenu(productListRenderer)
|
|
53
|
+
|
|
54
|
+
const { params, products } = useSearchOverlay()
|
|
55
|
+
|
|
56
|
+
const term = params.search
|
|
57
|
+
const noResult = products?.total_count === 0
|
|
58
|
+
|
|
59
|
+
const { events } = useRouter()
|
|
60
|
+
const closeOverlay = () => searchOverlayIsOpen.set(false)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
events.on('routeChangeStart', closeOverlay)
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
events.off('routeChangeStart', closeOverlay)
|
|
67
|
+
}
|
|
68
|
+
}, [events, products?.total_count])
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{!params.search && <SearchPlaceholder />}
|
|
73
|
+
{noResult && (
|
|
74
|
+
<SectionContainer labelLeft={<Trans id='Products'>Products</Trans>}>
|
|
75
|
+
<Trans>We couldn’t find any results for ‘{term}’</Trans>
|
|
76
|
+
</SectionContainer>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{params.search && products?.items && products.items.length > 0 && (
|
|
80
|
+
<SectionContainer
|
|
81
|
+
labelLeft={<Trans id='Products'>Products</Trans>}
|
|
82
|
+
labelRight={
|
|
83
|
+
<Link
|
|
84
|
+
color='secondary'
|
|
85
|
+
underline='hover'
|
|
86
|
+
href={productListLink({ ...params, pageSize: null })}
|
|
87
|
+
onClick={() => searchOverlayIsOpen.set(false)}
|
|
88
|
+
>
|
|
89
|
+
<Trans>View all</Trans> ({products.total_count})
|
|
90
|
+
</Link>
|
|
91
|
+
}
|
|
92
|
+
sx={(theme) => ({ pb: theme.spacing(2) })}
|
|
93
|
+
>
|
|
94
|
+
<ProductListItemsBase
|
|
95
|
+
renderers={rendererWithMenu}
|
|
96
|
+
loadingEager={6}
|
|
97
|
+
title={params.search ? `Search ${params.search}` : ''}
|
|
98
|
+
columns={(theme) => ({
|
|
99
|
+
xs: { count: 2 },
|
|
100
|
+
md: { count: 4, totalWidth: theme.breakpoints.values.md.toString() },
|
|
101
|
+
lg: { count: 4, totalWidth: theme.breakpoints.values.lg.toString() },
|
|
102
|
+
})}
|
|
103
|
+
items={products.items}
|
|
104
|
+
/>
|
|
105
|
+
</SectionContainer>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ProductListParams, ProductListQuery } from '@graphcommerce/magento-product'
|
|
2
|
+
import { ProductFiltersPro, toProductListParams } from '@graphcommerce/magento-product'
|
|
3
|
+
import { useForkRef } from '@mui/material'
|
|
4
|
+
import { motionValue } from 'framer-motion'
|
|
5
|
+
import { useRouter } from 'next/router'
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
+
import { useQuicksearch } from '../hooks/useQuicksearch'
|
|
9
|
+
|
|
10
|
+
export const searchOverlayIsOpen = motionValue(false)
|
|
11
|
+
|
|
12
|
+
type SearchOverlayContextType = {
|
|
13
|
+
params: ProductListParams
|
|
14
|
+
setParams: React.Dispatch<React.SetStateAction<ProductListParams>>
|
|
15
|
+
products: ProductListQuery['products']
|
|
16
|
+
setSelectedIndex: (index: number) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type SearchOverlaySelectionContextType = {
|
|
20
|
+
selectedIndex: number
|
|
21
|
+
items: React.RefObject<HTMLElement>[]
|
|
22
|
+
inputs: React.RefObject<HTMLElement>[]
|
|
23
|
+
registerItem: <T extends HTMLElement>(ref: React.RefObject<T>) => () => void
|
|
24
|
+
registerInput: <T extends HTMLElement>(ref: React.RefObject<T>) => () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SearchOverlayContext = createContext<SearchOverlayContextType | undefined>(undefined)
|
|
28
|
+
const SearchOverlaySelectionContext = createContext<SearchOverlaySelectionContextType | undefined>(
|
|
29
|
+
undefined,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export function useSearchOverlay() {
|
|
33
|
+
const context = useContext(SearchOverlayContext)
|
|
34
|
+
if (context === undefined) {
|
|
35
|
+
throw new Error('useSearchOverlay must be used within a SearchOverlayProvider')
|
|
36
|
+
}
|
|
37
|
+
return context
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type SearchOverlayProviderProps = {
|
|
41
|
+
children: ReactNode
|
|
42
|
+
open: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SearchOverlayProvider(props: SearchOverlayProviderProps) {
|
|
46
|
+
const { children, open, ...overlayProps } = props
|
|
47
|
+
const router = useRouter()
|
|
48
|
+
const [params, setParams] = useState<ProductListParams>({
|
|
49
|
+
filters: {},
|
|
50
|
+
sort: {},
|
|
51
|
+
url: 'search',
|
|
52
|
+
pageSize: 8,
|
|
53
|
+
currentPage: 1,
|
|
54
|
+
search: '',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const { handleSubmit, products } = useQuicksearch({ params })
|
|
58
|
+
|
|
59
|
+
const items = useRef<React.RefObject<HTMLElement>[]>([])
|
|
60
|
+
const inputs = useRef<React.RefObject<HTMLElement>[]>([])
|
|
61
|
+
const [selectedIndex, setSelectedIndex] = useState(-1)
|
|
62
|
+
|
|
63
|
+
const searchOverlayContext: SearchOverlayContextType = useMemo(
|
|
64
|
+
() => ({
|
|
65
|
+
params,
|
|
66
|
+
setParams,
|
|
67
|
+
products,
|
|
68
|
+
setSelectedIndex,
|
|
69
|
+
}),
|
|
70
|
+
[params, setParams, products, setSelectedIndex],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const searchOverlaySelectionContext: SearchOverlaySelectionContextType = useMemo(
|
|
74
|
+
() => ({
|
|
75
|
+
items: items.current,
|
|
76
|
+
inputs: inputs.current,
|
|
77
|
+
selectedIndex,
|
|
78
|
+
registerItem: <T extends HTMLElement>(ref: React.RefObject<T>) => {
|
|
79
|
+
if (ref.current instanceof HTMLElement) {
|
|
80
|
+
items.current.push(ref as React.RefObject<HTMLElement>)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
items.current = items.current.filter((i) => i !== ref)
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
registerInput: <T extends HTMLElement>(ref: React.RefObject<T>) => {
|
|
88
|
+
const controller = new AbortController()
|
|
89
|
+
if (ref.current instanceof HTMLElement) {
|
|
90
|
+
inputs.current.push(ref as React.RefObject<HTMLElement>)
|
|
91
|
+
|
|
92
|
+
ref.current.addEventListener(
|
|
93
|
+
'keydown',
|
|
94
|
+
(event) => {
|
|
95
|
+
if (event.key === 'ArrowDown') {
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
|
|
98
|
+
const newIndex = ((prevIndex) => {
|
|
99
|
+
if (prevIndex === items.current.length - 1) return -1
|
|
100
|
+
return (prevIndex + 1) % items.current.length
|
|
101
|
+
})(selectedIndex)
|
|
102
|
+
|
|
103
|
+
setSelectedIndex(newIndex)
|
|
104
|
+
|
|
105
|
+
items.current[newIndex]?.current?.scrollIntoView({
|
|
106
|
+
behavior: 'auto',
|
|
107
|
+
block: 'center',
|
|
108
|
+
})
|
|
109
|
+
} else if (event.key === 'ArrowUp') {
|
|
110
|
+
event.preventDefault()
|
|
111
|
+
|
|
112
|
+
const newIndex = ((prevIndex) => {
|
|
113
|
+
if (prevIndex === -1) return items.current.length - 1
|
|
114
|
+
return (prevIndex - 1) % items.current.length
|
|
115
|
+
})(selectedIndex)
|
|
116
|
+
|
|
117
|
+
setSelectedIndex(newIndex)
|
|
118
|
+
|
|
119
|
+
items.current[newIndex]?.current?.scrollIntoView({
|
|
120
|
+
behavior: 'auto',
|
|
121
|
+
block: 'center',
|
|
122
|
+
})
|
|
123
|
+
} else if (event.key === 'Enter') {
|
|
124
|
+
const element = items.current[selectedIndex]?.current
|
|
125
|
+
element?.click()
|
|
126
|
+
|
|
127
|
+
if (!element && params.search) {
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
129
|
+
router.push(`/search/${params.search}`)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
setSelectedIndex(-1)
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{ signal: controller.signal },
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return () => {
|
|
139
|
+
inputs.current = inputs.current.filter((i) => i !== ref)
|
|
140
|
+
controller.abort()
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
[params, router, selectedIndex],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<SearchOverlayContext.Provider value={searchOverlayContext}>
|
|
149
|
+
<SearchOverlaySelectionContext.Provider value={searchOverlaySelectionContext}>
|
|
150
|
+
<ProductFiltersPro
|
|
151
|
+
params={params}
|
|
152
|
+
filterTypes={{}}
|
|
153
|
+
autoSubmitMd
|
|
154
|
+
handleSubmit={(formValues) =>
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
156
|
+
handleSubmit(formValues, async () => {
|
|
157
|
+
setParams(toProductListParams(formValues))
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</ProductFiltersPro>
|
|
163
|
+
</SearchOverlaySelectionContext.Provider>
|
|
164
|
+
</SearchOverlayContext.Provider>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function useSearchOverlaySelection() {
|
|
169
|
+
const context = useContext(SearchOverlaySelectionContext)
|
|
170
|
+
if (context === undefined) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'useSearchOverlaySelection must be used within a SearchOverlaySelectionContext.Provider',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return context
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function useSearchItem({ rootRef }: { rootRef?: React.Ref<Element> }) {
|
|
179
|
+
const searchOverlay = useSearchOverlaySelection()
|
|
180
|
+
|
|
181
|
+
const internalRef = useRef<HTMLElement>(null)
|
|
182
|
+
const forkedRef = useForkRef(rootRef, internalRef)
|
|
183
|
+
const register = searchOverlay.registerItem
|
|
184
|
+
useEffect(() => register(internalRef), [register, rootRef])
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
getRootProps: () => ({
|
|
188
|
+
ref: forkedRef,
|
|
189
|
+
selected:
|
|
190
|
+
searchOverlay.selectedIndex > -1 &&
|
|
191
|
+
searchOverlay.selectedIndex === searchOverlay.items.indexOf(internalRef),
|
|
192
|
+
}),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function useSearchInput({ rootRef }: { rootRef?: React.Ref<Element> }) {
|
|
197
|
+
const searchOverlay = useSearchOverlaySelection()
|
|
198
|
+
|
|
199
|
+
const internalRef = useRef<HTMLElement>(null)
|
|
200
|
+
const forkedRef = useForkRef(rootRef, internalRef)
|
|
201
|
+
const register = searchOverlay.registerInput
|
|
202
|
+
useEffect(() => register(internalRef), [register, rootRef])
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
getRootProps: () => ({
|
|
206
|
+
selected: searchOverlay.selectedIndex === -1,
|
|
207
|
+
ref: forkedRef,
|
|
208
|
+
}),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SectionContainer } from '@graphcommerce/next-ui'
|
|
2
|
+
import { Trans } from '@lingui/react'
|
|
3
|
+
import { Box } from '@mui/material'
|
|
4
|
+
import { SearchOverlaySuggestion } from './SearchOverlaySuggestions'
|
|
5
|
+
|
|
6
|
+
export function SearchOverlayRecent({ recentSearches }: { recentSearches: string[] }) {
|
|
7
|
+
return (
|
|
8
|
+
<Box>
|
|
9
|
+
<SectionContainer labelLeft={<Trans id='Continue searching' />}>
|
|
10
|
+
{recentSearches.map((suggestion) => (
|
|
11
|
+
<SearchOverlaySuggestion key={suggestion} search={suggestion} />
|
|
12
|
+
))}
|
|
13
|
+
</SectionContainer>
|
|
14
|
+
</Box>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ProductListSearchSuggestionFragment } from '@graphcommerce/magento-product'
|
|
2
|
+
import { useProductFiltersPro } from '@graphcommerce/magento-product'
|
|
3
|
+
import type { SectionContainerProps } from '@graphcommerce/next-ui'
|
|
4
|
+
import { filterNonNullableKeys, SectionContainer } from '@graphcommerce/next-ui'
|
|
5
|
+
import { Trans } from '@lingui/macro'
|
|
6
|
+
import { forwardRef } from 'react'
|
|
7
|
+
import { useRecentSearches } from '../hooks/useRecentSearches'
|
|
8
|
+
import { SearchOverlayItem } from './SearchOverlayItem'
|
|
9
|
+
import { useSearchOverlay } from './SearchOverlayProvider'
|
|
10
|
+
|
|
11
|
+
type SearchOverlaySuggestionProps = ProductListSearchSuggestionFragment &
|
|
12
|
+
React.ComponentPropsWithoutRef<typeof SearchOverlayItem>
|
|
13
|
+
|
|
14
|
+
export const SearchOverlaySuggestion = forwardRef<HTMLLIElement, SearchOverlaySuggestionProps>(
|
|
15
|
+
(props, ref) => {
|
|
16
|
+
const { search, ...rest } = props
|
|
17
|
+
const { form } = useProductFiltersPro()
|
|
18
|
+
const { updateRecentSearches } = useRecentSearches()
|
|
19
|
+
const { setSelectedIndex } = useSearchOverlay()
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<SearchOverlayItem
|
|
23
|
+
ref={ref}
|
|
24
|
+
onClick={() => {
|
|
25
|
+
form.setValue('search', search)
|
|
26
|
+
updateRecentSearches(search)
|
|
27
|
+
setSelectedIndex(-1)
|
|
28
|
+
}}
|
|
29
|
+
{...rest}
|
|
30
|
+
>
|
|
31
|
+
{search}
|
|
32
|
+
</SearchOverlayItem>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
SearchOverlaySuggestion.displayName = 'SearchOverlaySuggestion'
|
|
38
|
+
|
|
39
|
+
type SearchOverlaySuggestionsProps = {
|
|
40
|
+
slotProps?: {
|
|
41
|
+
section?: SectionContainerProps
|
|
42
|
+
suggestion?: Omit<
|
|
43
|
+
React.ComponentProps<typeof SearchOverlaySuggestion>,
|
|
44
|
+
keyof ProductListSearchSuggestionFragment
|
|
45
|
+
>
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SearchOverlaySuggestions(props: SearchOverlaySuggestionsProps) {
|
|
50
|
+
const { products } = useSearchOverlay()
|
|
51
|
+
const { slotProps } = props
|
|
52
|
+
|
|
53
|
+
if (!products?.suggestions || products.suggestions.length === 0) return null
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<SectionContainer labelLeft={<Trans>Did you mean?</Trans>} {...slotProps?.section}>
|
|
57
|
+
{filterNonNullableKeys(products.suggestions).map((suggestion) => (
|
|
58
|
+
<SearchOverlaySuggestion
|
|
59
|
+
key={suggestion.search}
|
|
60
|
+
{...suggestion}
|
|
61
|
+
{...slotProps?.suggestion}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</SectionContainer>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FullPageMessage, iconSearch, IconSvg } from '@graphcommerce/next-ui'
|
|
2
|
+
import { Trans } from '@lingui/react'
|
|
3
|
+
import { useRecentSearches } from '../hooks/useRecentSearches'
|
|
4
|
+
import { SearchOverlayRecent } from './SearchOverlayRecent'
|
|
5
|
+
|
|
6
|
+
export function SearchPlaceholder() {
|
|
7
|
+
const { recentSearches } = useRecentSearches()
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{recentSearches.length > 0 ? (
|
|
12
|
+
<SearchOverlayRecent recentSearches={recentSearches} />
|
|
13
|
+
) : (
|
|
14
|
+
<FullPageMessage
|
|
15
|
+
title={<Trans id='What are you looking for?' />}
|
|
16
|
+
icon={<IconSvg src={iconSearch} size='xl' />}
|
|
17
|
+
disableMargin
|
|
18
|
+
>
|
|
19
|
+
<Trans id='Discover our collection by searching for products, categories or features.' />
|
|
20
|
+
</FullPageMessage>
|
|
21
|
+
)}
|
|
22
|
+
</>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { searchOverlayIsOpen } from '../components/SearchOverlayProvider'
|
|
3
|
+
|
|
4
|
+
export function useOpenWithShortKey() {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const handleKeyPress = (e: KeyboardEvent) => {
|
|
7
|
+
const { activeElement } = globalThis.document
|
|
8
|
+
const isInputFocused =
|
|
9
|
+
activeElement instanceof HTMLInputElement ||
|
|
10
|
+
activeElement instanceof HTMLTextAreaElement ||
|
|
11
|
+
activeElement?.hasAttribute('contenteditable')
|
|
12
|
+
|
|
13
|
+
if (e.code === 'Slash' && !isInputFocused) {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
searchOverlayIsOpen.set(true)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
19
|
+
e.preventDefault()
|
|
20
|
+
searchOverlayIsOpen.set(!searchOverlayIsOpen.get())
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
globalThis.document.addEventListener('keydown', handleKeyPress)
|
|
25
|
+
return () => globalThis.document.removeEventListener('keydown', handleKeyPress)
|
|
26
|
+
}, [])
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { usePrivateQuery, useQuery } from '@graphcommerce/graphql'
|
|
2
|
+
import type {
|
|
3
|
+
FilterFormProviderProps,
|
|
4
|
+
ProductFiltersQuery,
|
|
5
|
+
ProductListParams,
|
|
6
|
+
ProductListQuery,
|
|
7
|
+
} from '@graphcommerce/magento-product'
|
|
8
|
+
import {
|
|
9
|
+
prefetchProductList,
|
|
10
|
+
ProductListDocument,
|
|
11
|
+
toProductListParams,
|
|
12
|
+
} from '@graphcommerce/magento-product'
|
|
13
|
+
import {
|
|
14
|
+
productListApplySearchDefaults,
|
|
15
|
+
searchDefaultsToProductListFilters,
|
|
16
|
+
useProductListApplySearchDefaults,
|
|
17
|
+
} from '@graphcommerce/magento-search'
|
|
18
|
+
import { StoreConfigDocument } from '@graphcommerce/magento-store'
|
|
19
|
+
import { useEventCallback } from '@mui/material'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* - Handles shallow routing requests
|
|
23
|
+
* - Handles customer specific product list queries
|
|
24
|
+
* - Creates a prefetch function to preload the product list
|
|
25
|
+
*/
|
|
26
|
+
export function useQuicksearch<
|
|
27
|
+
T extends ProductListQuery & ProductFiltersQuery & { params?: ProductListParams },
|
|
28
|
+
>(props: T) {
|
|
29
|
+
const { params } = props
|
|
30
|
+
const variables = useProductListApplySearchDefaults(params)
|
|
31
|
+
const result = usePrivateQuery(
|
|
32
|
+
ProductListDocument,
|
|
33
|
+
{ variables: { ...variables, quickSearch: true }, skip: false || !params?.search },
|
|
34
|
+
props,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const storeConfig = useQuery(StoreConfigDocument).data
|
|
38
|
+
|
|
39
|
+
const handleSubmit: NonNullable<FilterFormProviderProps['handleSubmit']> = useEventCallback(
|
|
40
|
+
async (formValues, next) => {
|
|
41
|
+
if (!storeConfig) return
|
|
42
|
+
|
|
43
|
+
const vars = productListApplySearchDefaults(toProductListParams(formValues), storeConfig)
|
|
44
|
+
await prefetchProductList(
|
|
45
|
+
{ ...vars, quickSearch: true },
|
|
46
|
+
searchDefaultsToProductListFilters(vars),
|
|
47
|
+
next,
|
|
48
|
+
result.client,
|
|
49
|
+
true,
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return { ...props, ...result.data, params, mask: result.mask, handleSubmit }
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useRecentSearches() {
|
|
4
|
+
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const storedSearches: string[] = JSON.parse(localStorage.getItem('recentSearches') || '[]')
|
|
8
|
+
setRecentSearches(storedSearches)
|
|
9
|
+
}, [])
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
recentSearches,
|
|
13
|
+
updateRecentSearches: (searchTerm: string) => {
|
|
14
|
+
if (searchTerm?.length <= 3) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const formattedSearchTerm = searchTerm.charAt(0).toUpperCase() + searchTerm.slice(1)
|
|
18
|
+
const updatedSearches = new Set([formattedSearchTerm, ...recentSearches].slice(0, 5))
|
|
19
|
+
const newSearches = Array.from(updatedSearches)
|
|
20
|
+
localStorage.setItem('recentSearches', JSON.stringify(newSearches))
|
|
21
|
+
setRecentSearches(newSearches)
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useScrollY } from '@graphcommerce/next-ui'
|
|
2
|
+
import { useTheme } from '@mui/material'
|
|
3
|
+
import { useMotionValueEvent } from 'framer-motion'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
export function useStickyHeaderOnScroll() {
|
|
7
|
+
const scrollY = useScrollY()
|
|
8
|
+
const [scrolled, setScrolled] = useState(false)
|
|
9
|
+
const theme = useTheme()
|
|
10
|
+
const fixedThreshold = parseInt(theme.appShell.headerHeightSm, 10) / 2
|
|
11
|
+
|
|
12
|
+
useMotionValueEvent(scrollY, 'change', (latest) => {
|
|
13
|
+
if (latest >= fixedThreshold && !scrolled) {
|
|
14
|
+
setScrolled(true)
|
|
15
|
+
}
|
|
16
|
+
if (latest < fixedThreshold && scrolled) {
|
|
17
|
+
setScrolled(false)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (scrolled) {
|
|
23
|
+
globalThis.document.body.querySelector('.SearchOverlay-root')?.classList.add('scrolled')
|
|
24
|
+
} else {
|
|
25
|
+
globalThis.document.body.querySelector('.SearchOverlay-root')?.classList.remove('scrolled')
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/next-env.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@graphcommerce/magento-search-overlay",
|
|
3
|
+
"homepage": "https://www.graphcommerce.org/",
|
|
4
|
+
"repository": "github:graphcommerce-org/graphcommerce",
|
|
5
|
+
"version": "9.0.0-canary.117",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"prettier": "@graphcommerce/prettier-config-pwa",
|
|
8
|
+
"eslintConfig": {
|
|
9
|
+
"extends": "@graphcommerce/eslint-config-pwa",
|
|
10
|
+
"parserOptions": {
|
|
11
|
+
"project": "./tsconfig.json"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@graphcommerce/ecommerce-ui": "^9.0.0-canary.117",
|
|
16
|
+
"@graphcommerce/eslint-config-pwa": "^9.0.0-canary.117",
|
|
17
|
+
"@graphcommerce/framer-utils": "^9.0.0-canary.117",
|
|
18
|
+
"@graphcommerce/graphql": "^9.0.0-canary.117",
|
|
19
|
+
"@graphcommerce/image": "^9.0.0-canary.117",
|
|
20
|
+
"@graphcommerce/magento-product": "^9.0.0-canary.117",
|
|
21
|
+
"@graphcommerce/magento-search": "^9.0.0-canary.117",
|
|
22
|
+
"@graphcommerce/magento-store": "^9.0.0-canary.117",
|
|
23
|
+
"@graphcommerce/next-ui": "^9.0.0-canary.117",
|
|
24
|
+
"@graphcommerce/prettier-config-pwa": "^9.0.0-canary.117",
|
|
25
|
+
"@graphcommerce/react-hook-form": "^9.0.0-canary.117",
|
|
26
|
+
"@graphcommerce/typescript-config-pwa": "^9.0.0-canary.117",
|
|
27
|
+
"@lingui/core": "^4.2.1",
|
|
28
|
+
"@lingui/macro": "^4.2.1",
|
|
29
|
+
"@lingui/react": "^4.2.1",
|
|
30
|
+
"@mui/material": "^5.10.16",
|
|
31
|
+
"framer-motion": "*",
|
|
32
|
+
"next": "*",
|
|
33
|
+
"react": "^18.2.0",
|
|
34
|
+
"react-dom": "^18.2.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMotionValueValue } from '@graphcommerce/framer-utils'
|
|
2
|
+
import type { SearchFabProps } from '@graphcommerce/magento-search'
|
|
3
|
+
import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
|
|
4
|
+
import { searchOverlayLoading } from '../components/SearchOverlayLoader'
|
|
5
|
+
import { searchOverlayIsOpen } from '../components/SearchOverlayProvider'
|
|
6
|
+
import { useOpenWithShortKey } from '../hooks/useOpenWithShortKey'
|
|
7
|
+
|
|
8
|
+
export const config: PluginConfig = {
|
|
9
|
+
type: 'component',
|
|
10
|
+
module: '@graphcommerce/magento-search',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SearchFab(props: PluginProps<SearchFabProps>) {
|
|
14
|
+
const { Prev, ...rest } = props
|
|
15
|
+
const loading = useMotionValueValue(searchOverlayLoading, (v) => v)
|
|
16
|
+
useOpenWithShortKey()
|
|
17
|
+
return <Prev {...rest} loading={loading} onClick={() => searchOverlayIsOpen.set(true)} />
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SearchFieldProps } from '@graphcommerce/magento-search'
|
|
2
|
+
import { SearchFab as SearchFabBase } from '@graphcommerce/magento-search'
|
|
3
|
+
import type { PluginConfig, PluginProps } from '@graphcommerce/next-config'
|
|
4
|
+
import { useRouter } from 'next/router'
|
|
5
|
+
import type { SearchOverlayProps } from '../components/SearchOverlay'
|
|
6
|
+
import { SearchOverlayLoader } from '../components/SearchOverlayLoader'
|
|
7
|
+
|
|
8
|
+
export const config: PluginConfig = {
|
|
9
|
+
type: 'component',
|
|
10
|
+
module: '@graphcommerce/magento-search',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SearchField(
|
|
14
|
+
props: PluginProps<Omit<SearchFieldProps, 'searchField'>> & { searchField?: SearchOverlayProps },
|
|
15
|
+
) {
|
|
16
|
+
const { Prev, searchField, ...rest } = props
|
|
17
|
+
const isSearchPage = useRouter().asPath.startsWith('/search')
|
|
18
|
+
|
|
19
|
+
if (isSearchPage) return <Prev {...rest} />
|
|
20
|
+
if (!searchField) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'<SearchField searchField={{ productListRenderer }}/> required when rendering the SearchOverlay',
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<SearchFabBase size='large' slotProps={{ icon: { size: 'large' } }} {...rest.fab} />
|
|
29
|
+
<SearchOverlayLoader {...searchField} />
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|