diff --git a/.vscode/settings.json b/.vscode/settings.json index c0edc2635c..7fa3b31471 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,10 +2,12 @@ "editor.formatOnSave": true, "editor.tabSize": 2, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { "source.fixAll.eslint": false }, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "never" + }, "editor.snippetSuggestions": "none", "emmet.showExpandedAbbreviation": "never", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "javascript.suggest.names": false, "typescript.tsdk": "node_modules/typescript/lib", "search.exclude": { diff --git a/docs/framework/config.md b/docs/framework/config.md index 8979e76fc0..55562bcbdd 100644 --- a/docs/framework/config.md +++ b/docs/framework/config.md @@ -425,6 +425,6 @@ Number of recently viewed products to be stored in localStorage SidebarGalleryConfig will contain all configuration values for the Sidebar Gallery component. -#### paginationVariant: 'DOTS' | 'THUMBNAILS_BOTTOM' +#### paginationVariant: 'DOTS' | 'GRID' | 'THUMBNAILS_BOTTOM' Variant used for the pagination \ No newline at end of file diff --git a/examples/magento-graphcms/pages/blog/[url].tsx b/examples/magento-graphcms/pages/blog/[url].tsx index 2b0437f257..07ccc7fc05 100644 --- a/examples/magento-graphcms/pages/blog/[url].tsx +++ b/examples/magento-graphcms/pages/blog/[url].tsx @@ -1,6 +1,6 @@ import { PageOptions } from '@graphcommerce/framer-next-pages' import { hygraphPageContent, HygraphPagesQuery } from '@graphcommerce/graphcms-ui' -import { StoreConfigDocument } from '@graphcommerce/magento-store' +import { redirectOrNotFound, StoreConfigDocument } from '@graphcommerce/magento-store' import { PageMeta, BlogTitle, @@ -94,6 +94,7 @@ export const getStaticProps: GetPageStaticProps = async ({ locale, params }) => query: BlogListDocument, variables: { currentUrl: [`blog/${urlKey}`], first: limit }, }) + if (!(await page).data.pages?.[0]) return { notFound: true } return { diff --git a/packages/framer-scroller/components/ScrollerProvider.tsx b/packages/framer-scroller/components/ScrollerProvider.tsx index d6676309d6..04c856b089 100644 --- a/packages/framer-scroller/components/ScrollerProvider.tsx +++ b/packages/framer-scroller/components/ScrollerProvider.tsx @@ -166,6 +166,8 @@ export function ScrollerProvider(props: ScrollerProviderProps) { const itemsArr: unknown[] = items.get().slice() itemsArr.length = count + console.log('Registering children', itemsArr) + items.set( itemsArr.fill(undefined).map((_, i) => ({ visibility: motionValue(i === 0 ? 1 : 0), diff --git a/packages/magento-pagebuilder/ContentTypes/Slider/AutoScroll.tsx b/packages/magento-pagebuilder/ContentTypes/Slider/AutoScroll.tsx index e8a289dfab..622fbafbc2 100644 --- a/packages/magento-pagebuilder/ContentTypes/Slider/AutoScroll.tsx +++ b/packages/magento-pagebuilder/ContentTypes/Slider/AutoScroll.tsx @@ -7,7 +7,7 @@ type AutoScrollProps = { } export function AutoScroll(props: AutoScrollProps) { - const { getScrollSnapPositions, getSnapPosition, scrollerRef, scroll } = useScrollerContext() + const { getScrollSnapPositions, getSnapPosition, scroll } = useScrollerContext() const scrollTo = useScrollTo() const { pause = false, timePerSlide = 7500 } = props const { xProgress } = scroll diff --git a/packages/magento-product/hooks/useStickyEffect.ts b/packages/magento-product/hooks/useStickyEffect.ts new file mode 100644 index 0000000000..ac53ebf4fc --- /dev/null +++ b/packages/magento-product/hooks/useStickyEffect.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' + +export function useStickyEffect() { + const marginRef = useRef(null) + const sidebarRef = useRef(null) + const wrapperRef = useRef(null) + + const lastScrollTop = useRef(typeof document === 'undefined' ? 0 : document.body.scrollTop) + + const handleScroll = () => { + const marginElement = marginRef.current + const sidebarElement = sidebarRef.current + const wrapperElement = wrapperRef.current + + if (!(marginElement && sidebarElement && wrapperElement)) return + + const { height: sidebarHeight } = sidebarElement.getBoundingClientRect() + const { scrollTop } = document.documentElement + const scrollDirection = scrollTop > lastScrollTop.current ? 1 : 0 + + if (lastScrollTop.current === scrollTop || sidebarHeight <= window.innerHeight) { + sidebarElement.style.top = '0px' + sidebarElement.style.bottom = '' + marginElement.style.marginTop = '' + return + } + + const wrapperBounds = wrapperElement.getBoundingClientRect() + const sidebarBounds = sidebarElement.getBoundingClientRect() + + marginElement.style.marginTop = `${sidebarBounds.top - wrapperBounds.top}px` + + if (scrollDirection === 1) { + sidebarElement.style.bottom = '' + sidebarElement.style.top = `-${sidebarHeight - window.innerHeight}px` + } else { + sidebarElement.style.top = '' + sidebarElement.style.bottom = `-${sidebarHeight - window.innerHeight}px` + } + + lastScrollTop.current = scrollTop + } + + useEffect(() => { + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + return [marginRef, sidebarRef, wrapperRef] +} diff --git a/packages/next-ui/Config.graphqls b/packages/next-ui/Config.graphqls index 2bef3570cd..2db26f21a0 100644 --- a/packages/next-ui/Config.graphqls +++ b/packages/next-ui/Config.graphqls @@ -11,6 +11,7 @@ Enumeration of all possible positions for the sidebar gallery thumbnails. enum SidebarGalleryPaginationVariant { DOTS THUMBNAILS_BOTTOM + GRID } """ diff --git a/packages/next-ui/FramerScroller/GalleryZoom.tsx b/packages/next-ui/FramerScroller/GalleryZoom.tsx new file mode 100644 index 0000000000..69793a6f7c --- /dev/null +++ b/packages/next-ui/FramerScroller/GalleryZoom.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { UseGalleryZoomProps, useGalleryZoom } from '../hooks/useGalleryZoom' + +type GalleryZoomProps = UseGalleryZoomProps & { + children?: (props: ReturnType) => React.ReactNode +} + +export function GalleryZoom(props: GalleryZoomProps) { + const { children, ...rest } = props + const galleryZoom = useGalleryZoom(rest) + return <>{children?.(galleryZoom)} +} diff --git a/packages/next-ui/FramerScroller/SidebarGallery.tsx b/packages/next-ui/FramerScroller/SidebarGallery.tsx index 590b302a92..c0363c8b4d 100644 --- a/packages/next-ui/FramerScroller/SidebarGallery.tsx +++ b/packages/next-ui/FramerScroller/SidebarGallery.tsx @@ -1,4 +1,3 @@ -import { usePrevPageRouter } from '@graphcommerce/framer-next-pages/hooks/usePrevPageRouter' import { MotionImageAspect, MotionImageAspectProps, @@ -6,7 +5,6 @@ import { ScrollerDots, ScrollerButton, ScrollerProvider, - unstable_usePreventScroll as usePreventScroll, ScrollerButtonProps, ScrollerThumbnails, } from '@graphcommerce/framer-scroller' @@ -20,14 +18,16 @@ import { Theme, Unstable_TrapFocus as TrapFocus, } from '@mui/material' -import { m, useDomEvent, useMotionValue } from 'framer-motion' +import { m } from 'framer-motion' import { useRouter } from 'next/router' -import React, { useEffect, useRef } from 'react' +import React from 'react' import { IconSvg } from '../IconSvg' import { Row } from '../Row/Row' import { extendableComponent } from '../Styles' import { responsiveVal } from '../Styles/responsiveVal' import { iconChevronLeft, iconChevronRight, iconFullscreen, iconFullscreenExit } from '../icons' +import { GalleryZoom } from './GalleryZoom' +import { useStickyEffect } from './hooks/useStickyEffect' const MotionBox = styled(m.div)({}) @@ -72,318 +72,269 @@ export function SidebarGallery(props: SidebarGalleryProps) { showButtons, disableZoom = false, } = props - + const theme = useTheme() const router = useRouter() - const prevRoute = usePrevPageRouter() - // const classes = useMergedClasses(useStyles({ clientHeight, aspectRatio }).classes, props.classes) - const route = `#${routeHash}` - // We're using the URL to manage the state of the gallery. - const zoomed = router.asPath.endsWith(route) - usePreventScroll(zoomed) - - // cleanup if someone enters the page with #gallery - useEffect(() => { - if (!prevRoute?.pathname && zoomed) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - router.replace(router.asPath.replace(route, '')) - } - }, [prevRoute?.pathname, route, router, zoomed]) - - const toggle = () => { - if (disableZoom) { - return - } - if (!zoomed) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - router.push(route, undefined, { shallow: true }) - window.scrollTo({ top: 0, behavior: 'smooth' }) - } else { - router.back() - } - } + const zoomed = router.asPath.split('@')[0].endsWith(route) + const [marginRef, sidebarRef, wrapperRef] = useStickyEffect() const classes = withState({ zoomed, disableZoom }) - const theme = useTheme() - const windowRef = useRef(typeof window !== 'undefined' ? window : null) - - const handleEscapeKey = (e: KeyboardEvent | Event) => { - if (zoomed && (e as KeyboardEvent)?.key === 'Escape') toggle() - } - - const dragStart = useMotionValue(0) - const onMouseDownScroller: React.MouseEventHandler = (e) => { - if (dragStart.get() === e.clientX) return - dragStart.set(e.clientX) - } - const onMouseUpScroller: React.MouseEventHandler = (e) => { - const currentDragLoc = e.clientX - if (Math.abs(currentDragLoc - dragStart.get()) < 8) toggle() - } - - useDomEvent(windowRef, 'keyup', handleEscapeKey, { passive: true }) - - const headerHeight = `${theme.appShell.headerHeightSm} - ${theme.spacings.sm} * 2` - const galleryMargin = theme.spacings.lg - - const maxHeight = `calc(100vh - ${headerHeight} - ${galleryMargin})` - const ratio = `calc(${height} / ${width} * 100%)` const hasImages = images.length > 0 return ( - - - + + {({ maxHeight, onMouseDownScroller, onMouseUpScroller, ratio, toggle }) => ( + { - if (!zoomed) document.body.style.overflow = '' - }} > - - {images.map((image, idx) => ( - + { + if (!zoomed) document.body.style.overflow = '' + }} + > + + {images.map((image, idx) => ( + toggle(idx)} + src={image.src} + width={image.width} + height={image.height} + loading={idx === 0 ? 'eager' : 'lazy'} + sx={{ display: 'block', objectFit: 'contain' }} + sizes={{ + 0: '100vw', + [theme.breakpoints.values.md]: zoomed ? '100vw' : '60vw', + }} + alt={image.alt || `Product Image ${idx}` || undefined} + dontReportWronglySizedImages + /> + ))} + + + {!disableZoom && ( + toggle()} + aria-label='Toggle Fullscreen' + sx={{ boxShadow: 6 }} + > + {!zoomed ? ( + + ) : ( + + )} + + )} + + - ))} - - - {!disableZoom && ( - - {!zoomed ? ( - + + + + + + + + + + + *': { + pointerEvents: 'all', + }, + }} + > + {import.meta.graphCommerce.sidebarGallery?.paginationVariant === + 'THUMBNAILS_BOTTOM' ? ( + ) : ( - + )} - - )} - + + + - - - - - - + - - + {sidebar} + - - *': { - pointerEvents: 'all', - }, - }} - > - {import.meta.graphCommerce.sidebarGallery?.paginationVariant === - 'THUMBNAILS_BOTTOM' ? ( - - ) : ( - - )} - - - - - - {sidebar} - - - + + )} + ) } diff --git a/packages/next-ui/FramerScroller/hooks/useStickyEffect.ts b/packages/next-ui/FramerScroller/hooks/useStickyEffect.ts new file mode 100644 index 0000000000..e8b8a5fe59 --- /dev/null +++ b/packages/next-ui/FramerScroller/hooks/useStickyEffect.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react' + +export function useStickyEffect() { + const marginRef = useRef(null) + const sidebarRef = useRef(null) + const wrapperRef = useRef(null) + + const lastScrollTop = useRef(typeof document === 'undefined' ? 0 : document.body.scrollTop) + + const handleScroll = () => { + const marginElement = marginRef.current + const sidebarElement = sidebarRef.current + const wrapperElement = wrapperRef.current + + if (!(marginElement && sidebarElement && wrapperElement)) return + + const { height: sidebarHeight } = sidebarElement.getBoundingClientRect() + + const { scrollTop } = document.documentElement + const scrollDirection = scrollTop > lastScrollTop.current ? 1 : 0 + + if (lastScrollTop.current === scrollTop || sidebarHeight <= window.innerHeight) { + sidebarElement.style.top = '0px' + sidebarElement.style.bottom = '' + marginElement.style.marginTop = '' + return + } + + const wrapperBounds = wrapperElement.getBoundingClientRect() + const sidebarBounds = sidebarElement.getBoundingClientRect() + + marginElement.style.marginTop = `${sidebarBounds.top - wrapperBounds.top}px` + + if (scrollDirection === 1) { + sidebarElement.style.bottom = '' + sidebarElement.style.top = `-${sidebarHeight - window.innerHeight}px` + } else { + sidebarElement.style.top = '' + sidebarElement.style.bottom = `-${sidebarHeight - window.innerHeight}px` + } + + lastScrollTop.current = scrollTop + } + + useEffect(() => { + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + return [marginRef, sidebarRef, wrapperRef] +} diff --git a/packages/next-ui/hooks/useGalleryZoom.ts b/packages/next-ui/hooks/useGalleryZoom.ts new file mode 100644 index 0000000000..14c7dc36e1 --- /dev/null +++ b/packages/next-ui/hooks/useGalleryZoom.ts @@ -0,0 +1,91 @@ +import { usePrevPageRouter } from '@graphcommerce/framer-next-pages' +import { + unstable_usePreventScroll as usePreventScroll, + useScrollerContext, +} from '@graphcommerce/framer-scroller' +import { useTheme } from '@mui/material' +import { useDomEvent, useMotionValue, useMotionValueEvent } from 'framer-motion' +import { useRouter } from 'next/router' +import { useEffect, useRef } from 'react' + +export type UseGalleryZoomProps = { + disableZoom?: boolean + routeHash: string + width: number + height: number +} + +export function useGalleryZoom(props: UseGalleryZoomProps) { + const { disableZoom, routeHash, width, height } = props + const router = useRouter() + const prevRoute = usePrevPageRouter() + const { getScrollSnapPositions, scrollerRef, scroll, disableSnap, enableSnap } = + useScrollerContext() + + const route = `#${routeHash}` + // We're using the URL to manage the state of the gallery. + const zoomed = router.asPath.split('@')[0].endsWith(route) + // usePreventScroll(zoomed) + + useMotionValueEvent(scroll.x, 'change', (v) => console.log(v)) + + // cleanup if someone enters the page with #gallery + useEffect(() => { + if (!prevRoute?.pathname && zoomed) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + router.replace(router.asPath.replace(route, '')) + } + }, [prevRoute?.pathname, route, router, zoomed]) + + const toggle = (index?: number) => { + if (disableZoom) { + return + } + if (!zoomed) { + const scroller = scrollerRef.current + if (index && scroller) { + disableSnap() + scroller.scrollLeft = 1000 + scroll.x.set(1000) + } + window.scrollTo({ top: 0, behavior: 'smooth' }) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + router.push(`${route}${index ? `@${index}` : ''}`, undefined, { shallow: true }) + } else { + router.back() + } + } + + const theme = useTheme() + const windowRef = useRef(typeof window !== 'undefined' ? window : null) + + const handleEscapeKey = (e: KeyboardEvent | Event) => { + if (zoomed && (e as KeyboardEvent)?.key === 'Escape') toggle() + } + + const dragStart = useMotionValue(0) + const onMouseDownScroller: React.MouseEventHandler = (e) => { + if (dragStart.get() === e.clientX) return + dragStart.set(e.clientX) + } + const onMouseUpScroller: React.MouseEventHandler = (e) => { + const currentDragLoc = e.clientX + if (Math.abs(currentDragLoc - dragStart.get()) < 8) toggle() + } + + useDomEvent(windowRef, 'keyup', handleEscapeKey, { passive: true }) + + const headerHeight = `${theme.appShell.headerHeightSm} - ${theme.spacings.sm} * 2` + const galleryMargin = theme.spacings.lg + + const maxHeight = `calc(100vh - ${headerHeight} - ${galleryMargin})` + const ratio = `calc(${height} / ${width} * 100%)` + + return { + maxHeight, + ratio, + onMouseDownScroller, + onMouseUpScroller, + toggle, + } +} diff --git a/packages/next-ui/package.json b/packages/next-ui/package.json index 4fba3aca58..cee0e2ed62 100644 --- a/packages/next-ui/package.json +++ b/packages/next-ui/package.json @@ -31,6 +31,7 @@ "@graphcommerce/framer-scroller": "^7.1.0-canary.65", "@graphcommerce/framer-utils": "^7.1.0-canary.65", "@graphcommerce/image": "^7.1.0-canary.65", + "@graphcommerce/next-config": "^7.1.0-canary.65", "@graphcommerce/prettier-config-pwa": "^7.1.0-canary.65", "@graphcommerce/typescript-config-pwa": "^7.1.0-canary.65", "@lingui/core": "^4.2.1", diff --git a/packages/next-ui/plugins/SidebarGalleryGridPlugin.tsx b/packages/next-ui/plugins/SidebarGalleryGridPlugin.tsx new file mode 100644 index 0000000000..59580696b1 --- /dev/null +++ b/packages/next-ui/plugins/SidebarGalleryGridPlugin.tsx @@ -0,0 +1,68 @@ +import { IfConfig, PluginProps } from '@graphcommerce/next-config' +import { SxProps, Theme, useTheme } from '@mui/material' +import { useRouter } from 'next/router' +import { SidebarGalleryProps } from '../FramerScroller' + +export const component = 'SidebarGallery' +export const exported = '@graphcommerce/next-ui' +export const ifConfig: IfConfig = 'sidebarGallery.paginationVariant' + +function SidebarGalleryGridPlugin(props: PluginProps) { + const { Prev, sx = {}, disableZoom, routeHash = 'gallery', ...rest } = props + const theme = useTheme() + const router = useRouter() + const newSx = Array.isArray(sx) ? sx : [sx] + const route = `#${routeHash}` + const zoomed = router.asPath.split('@')[0].endsWith(route) + + const gridSx: SxProps = { + '& .ScrollerDots-root': { display: 'none' }, + [theme.breakpoints.up('md')]: { + height: 'unset', + top: 'unset', + '& .SidebarGallery-scrollerContainer': { + paddingTop: 'unset', + height: 'unset', + }, + '& .Scroller-root': { + position: 'relative', + }, + '& div > .mdSnapDirInline': { + overflowY: 'unset', + overflowX: 'unset', + }, + '& div > .mdGridDirInline': { + gridAutoColumns: 'unset', + gridAutoRows: 'unset', + gridTemplateRows: 'unset', + gridAutoFlow: 'unset', + gridTemplateColumns: 'repeat(2, 1fr)', + '& > *:last-of-type:nth-of-type(odd)': { + gridColumn: '1 / -1', + }, + '& picture': { + aspectRatio: '1 !important', + maxHeight: 'unset', + width: 'unset', + height: 'unset', + top: 'unset', + left: 'unset', + transform: 'unset', + position: 'unset', + '&::after': { + minWidth: 'unset', + }, + '& img': { + objectFit: 'cover', + }, + }, + }, + }, + } + if (import.meta.graphCommerce.sidebarGallery?.paginationVariant === 'GRID' && !zoomed) + newSx.push(gridSx) + + return +} + +export const Plugin = SidebarGalleryGridPlugin diff --git a/packagesDev/next-config/dist/generated/config.js b/packagesDev/next-config/dist/generated/config.js index 05666c3ba8..e97ea2be96 100644 --- a/packagesDev/next-config/dist/generated/config.js +++ b/packagesDev/next-config/dist/generated/config.js @@ -56,6 +56,7 @@ const ProductFiltersLayoutSchema = _zod.z.enum([ ]); const SidebarGalleryPaginationVariantSchema = _zod.z.enum([ "DOTS", + "GRID", "THUMBNAILS_BOTTOM" ]); function GraphCommerceConfigSchema() { diff --git a/packagesDev/next-config/src/generated/config.ts b/packagesDev/next-config/src/generated/config.ts index bdc644ee0e..76c18182f3 100644 --- a/packagesDev/next-config/src/generated/config.ts +++ b/packagesDev/next-config/src/generated/config.ts @@ -413,6 +413,7 @@ export type SidebarGalleryConfig = { /** Enumeration of all possible positions for the sidebar gallery thumbnails. */ export type SidebarGalleryPaginationVariant = | 'DOTS' + | 'GRID' | 'THUMBNAILS_BOTTOM'; @@ -430,7 +431,7 @@ export const CompareVariantSchema = z.enum(['CHECKBOX', 'ICON']); export const ProductFiltersLayoutSchema = z.enum(['DEFAULT', 'SIDEBAR']); -export const SidebarGalleryPaginationVariantSchema = z.enum(['DOTS', 'THUMBNAILS_BOTTOM']); +export const SidebarGalleryPaginationVariantSchema = z.enum(['DOTS', 'GRID', 'THUMBNAILS_BOTTOM']); export function GraphCommerceConfigSchema(): z.ZodObject> { return z.object({ diff --git a/yarn.lock b/yarn.lock index 072ef19310..d24a897142 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4266,6 +4266,7 @@ __metadata: "@graphcommerce/framer-scroller": ^7.1.0-canary.65 "@graphcommerce/framer-utils": ^7.1.0-canary.65 "@graphcommerce/image": ^7.1.0-canary.65 + "@graphcommerce/next-config": ^7.1.0-canary.65 "@graphcommerce/prettier-config-pwa": ^7.1.0-canary.65 "@graphcommerce/typescript-config-pwa": ^7.1.0-canary.65 "@lingui/core": ^4.2.1