@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 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,8 @@
1
+ # Magento Search Overlay
2
+
3
+ ## Installation
4
+
5
+ 1. Find current version of your `@graphcommerce/magento-search` in your
6
+ package.json.
7
+ 2. `yarn add @graphcommerce/magento-search@1.2.3` (replace 1.2.3 with the
8
+ version of the step above)
@@ -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
@@ -0,0 +1,4 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/types/global" />
3
+ /// <reference types="next/image-types/global" />
4
+ /// <reference types="@graphcommerce/next-ui/types" />
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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "exclude": ["**/node_modules", "**/.*/"],
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "extends": "@graphcommerce/typescript-config-pwa/nextjs.json"
5
+ }