-
Notifications
You must be signed in to change notification settings - Fork 142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Page Designer Carousel Component #977
Changes from 14 commits
b2ca364
c1a7c29
6b5a05e
aa51d10
d37654a
b28400b
21dc803
b1bb944
915c855
66e24ce
e34d33f
d294ae9
2bb5ed3
95e955d
2924799
797d374
df70ef9
f494ea1
20e300f
dcdfb6b
24900bb
58965a1
eb4594d
f39b703
39018bd
9b86790
e76c57e
cd028eb
fe9eef4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,236 @@ | ||
/* | ||
* Copyright (c) 2023, Salesforce, Inc. | ||
* All rights reserved. | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
*/ | ||
import React, {Fragment, useCallback, useMemo, useRef, useState} from 'react' | ||
import PropTypes from 'prop-types' | ||
import { | ||
AspectRatio, | ||
Box, | ||
Heading, | ||
IconButton, | ||
Stack, | ||
useBreakpoint, | ||
useBreakpointValue | ||
} from '@chakra-ui/react' | ||
import {Component} from 'commerce-sdk-react-preview/components' | ||
import {ChevronLeftIcon, ChevronRightIcon} from '../../icons' | ||
import {useEffect} from 'react' | ||
|
||
/** | ||
* Display child components in a carousel slider manner. Configurations include the number of | ||
* children to display in view as well as whether or not to show controls and position indicators. | ||
* | ||
* @param {PageProps} props | ||
* @param {string} props.textHeading - Heading text for the carousel. | ||
* @param {boolean} props.xsCarouselIndicators - Show/Hide carousel indecators/pips on "xs" screens. | ||
* @param {boolean} props.smCarouselIndicators - Show/Hide carousel indecators/pips on "sm" screens. | ||
* @param {boolean} props.mdCarouselIndicators - Show/Hide carousel indecators/pips on "md" screens. | ||
* @param {boolean} props.xsCarouselControls - Show/Hide carousel forward/back controls on "xs" screens. | ||
* @param {boolean} props.smCarouselControls - Show/Hide carousel forward/back controls on "sm" screens. | ||
* @param {number} props.xsCarouselSlidesToDisplay - Number of children that will be rendered in view on "xs" screens. | ||
* @param {number} props.smCarouselSlidesToDisplay - Number of children that will be rendered in view on "sm" screens. | ||
* @param {number} props.mdCarouselSlidesToDisplay - Number of children that will be rendered in view on "md" screens. | ||
* @param {Object []} props.region - The regions passed internally to this component by the `commerce-sdk-react` Page component. | ||
* @returns {React.ReactElement} - Crousel component. | ||
*/ | ||
const Carousel = (props = {}) => { | ||
const scrollRef = useRef() | ||
const breakpoint = useBreakpoint() | ||
const [hasOverflow, setHasOverflow] = useState(false) | ||
|
||
const { | ||
textHeadline, | ||
xsCarouselIndicators = false, | ||
smCarouselIndicators = false, | ||
mdCarouselIndicators = false, | ||
xsCarouselControls = false, | ||
smCarouselControls = false, | ||
xsCarouselSlidesToDisplay = 1, | ||
smCarouselSlidesToDisplay = 1, | ||
mdCarouselSlidesToDisplay = 1, | ||
// Internally Provided | ||
regions | ||
} = props | ||
|
||
const controlDisplay = useMemo(() => { | ||
return { | ||
base: xsCarouselControls && hasOverflow ? 'block' : 'none', | ||
sm: xsCarouselControls && hasOverflow ? 'block' : 'none', | ||
md: smCarouselControls && hasOverflow ? 'block' : 'none', | ||
lg: hasOverflow ? 'block' : 'none' | ||
} | ||
}, [hasOverflow]) | ||
|
||
const itemWidth = { | ||
base: `calc(${100 / xsCarouselSlidesToDisplay}%)`, | ||
sm: `calc(${100 / xsCarouselSlidesToDisplay}%)`, | ||
md: `calc(${100 / smCarouselSlidesToDisplay}%)`, | ||
lg: `calc(${100 / mdCarouselSlidesToDisplay}%)` | ||
} | ||
|
||
const overflowXScroll = { | ||
base: xsCarouselIndicators ? 'block' : 'none', | ||
sm: xsCarouselIndicators ? 'block' : 'none', | ||
md: smCarouselIndicators ? 'block' : 'none', | ||
lg: mdCarouselIndicators ? 'block' : 'none' | ||
} | ||
const overflowXScrollValue = useBreakpointValue(overflowXScroll) | ||
|
||
const components = regions[0]?.components || [] | ||
const itemCount = components.length | ||
|
||
// Scroll the container left or right by 100%. Passing no args or `1` | ||
// scrolls to the right, and passing `-1` scrolls left. | ||
const scroll = useCallback((direction = 1) => { | ||
scrollRef.current?.scrollBy({ | ||
top: 0, | ||
left: (direction * window.innerWidth) / itemCount, | ||
behavior: 'smooth' | ||
}) | ||
}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The scrolling in the SFRA carousel by default implements an infinite loop behaviour when scrolling in either direction. Is this something we need to implement? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When this PR was first estimated, it was estimated with the idea that we were going to reuse the code from the product scroller. Because this implementation uses a simple overflow with snap, it would be significant effort to make the carousel behave exactly like sfra. I think it's ok to not verbatim match srfa's implementation, instead be more inline with the PWA implementation |
||
|
||
// Our indicator implementation uses the scrollbar to show the context of the current | ||
// item selected. Because MacOS hides scroll bars after they come to rest we need to | ||
// force them to show. Please note that this feature only works on web-kit browsers, | ||
// for all other brosers the scroller/indicator will be shown. | ||
const style = { | ||
'.scroll-indicator::-webkit-scrollbar': { | ||
display: overflowXScrollValue, | ||
['-webkit-appearance']: `none`, | ||
height: `8px` | ||
}, | ||
'.scroll-indicator::-webkit-scrollbar-thumb': { | ||
backgroundColor: 'rgba(0, 0, 0, 0.5)' | ||
} | ||
} | ||
|
||
useEffect(() => { | ||
const {clientWidth, scrollWidth} = scrollRef.current | ||
setHasOverflow(scrollWidth > clientWidth) | ||
}, [breakpoint, props]) | ||
|
||
return ( | ||
<Box className={'carousel'} sx={style} position="relative" data-testid="carousel"> | ||
<Stack className={'carousel-container'} data-testid="carousel-container" spacing={6}> | ||
{textHeadline && ( | ||
<Heading as="h2" fontSize="xl" textAlign="center"> | ||
{textHeadline} | ||
</Heading> | ||
)} | ||
|
||
<Stack | ||
ref={scrollRef} | ||
className={'carousel-container-items scroll-indicator'} | ||
data-testid="carousel-container-items" | ||
direction="row" | ||
spacing={0} | ||
wrap="nowrap" | ||
overflowX="scroll" | ||
sx={{ | ||
scrollPadding: 0, | ||
scrollSnapType: 'x mandatory', | ||
WebkitOverflowScrolling: 'touch' | ||
}} | ||
> | ||
{components.map((component, index) => ( | ||
<Box | ||
key={component?.id || index} | ||
flex="0 0 auto" | ||
width={itemWidth} | ||
style={{scrollSnapAlign: 'start'}} | ||
> | ||
<AspectRatio ratio={1}> | ||
<Component component={component} /> | ||
</AspectRatio> | ||
</Box> | ||
))} | ||
</Stack> | ||
</Stack> | ||
|
||
{/* Indicators */} | ||
<Fragment> | ||
<Box | ||
id="dots" | ||
position="absolute" | ||
bottom="10px" | ||
left="50%" | ||
transform="translateX(-50%)" | ||
style={{ | ||
paddingLeft: '5px', | ||
paddingRight: '5px', | ||
borderRadius: '10px', | ||
height: '30px', | ||
lineHeight: '20px', | ||
background: 'rgba(0, 0, 0, 0.5)' | ||
}} | ||
> | ||
{components.map((_, index) => ( | ||
<a | ||
key={index} | ||
style={{fontSize: '50px', color: 'white', opacity: '0.5'}} | ||
> | ||
• | ||
</a> | ||
))} | ||
</Box> | ||
</Fragment> | ||
|
||
{/* Button Controls */} | ||
<Fragment> | ||
<Box | ||
display={controlDisplay} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SFRA carousel only shows the control buttons when the items don't fit in the current view. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is valuable. I've added logic to detect if there is overflow and only if there is overflow will it obey the control visibility prop configurations. |
||
position="absolute" | ||
top="50%" | ||
left={{base: 0, lg: 4}} | ||
transform="translateY(-50%)" | ||
> | ||
<IconButton | ||
data-testid="carousel-nav-left" | ||
aria-label="Scroll carousel left" | ||
icon={<ChevronLeftIcon color="black" />} | ||
borderRadius="full" | ||
colorScheme="whiteAlpha" | ||
onClick={() => scroll(-1)} | ||
/> | ||
</Box> | ||
|
||
<Box | ||
display={controlDisplay} | ||
position="absolute" | ||
top="50%" | ||
right={{base: 0, lg: 4}} | ||
transform="translateY(-50%)" | ||
> | ||
<IconButton | ||
data-testid="carousel-nav-right" | ||
aria-label="Scroll carousel right" | ||
icon={<ChevronRightIcon color="black" />} | ||
borderRadius="full" | ||
colorScheme="whiteAlpha" | ||
onClick={() => scroll(1)} | ||
/> | ||
</Box> | ||
</Fragment> | ||
</Box> | ||
) | ||
} | ||
|
||
Carousel.propTypes = { | ||
regions: PropTypes.array.isRequired, | ||
textHeadline: PropTypes.string, | ||
xsCarouselIndicators: PropTypes.bool, | ||
smCarouselIndicators: PropTypes.bool, | ||
mdCarouselIndicators: PropTypes.bool, | ||
xsCarouselControls: PropTypes.bool, | ||
smCarouselControls: PropTypes.bool, | ||
xsCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]), | ||
smCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]), | ||
mdCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]) | ||
} | ||
|
||
Carousel.displayName = 'Carousel' | ||
|
||
export default Carousel |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
/* | ||
* Copyright (c) 2023, salesforce.com, inc. | ||
* All rights reserved. | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
*/ | ||
import React from 'react' | ||
import {renderWithProviders, withPageProvider} from '../../../utils/test-utils' | ||
import Carousel from './index' | ||
|
||
const SAMPLE_REGION = { | ||
id: 'TEST_REGION', | ||
components: [ | ||
{ | ||
id: 'TEST_COMPONENT', | ||
typeId: 'test-component', | ||
data: {} | ||
} | ||
] | ||
} | ||
|
||
test('Carousel renders without errors', () => { | ||
const {getByTestId} = renderWithProviders(<Carousel regions={[]} />) | ||
|
||
expect(getByTestId('carousel')).toBeDefined() | ||
expect(getByTestId('carousel-container')).toBeDefined() | ||
expect(getByTestId('carousel-container-items')).toBeDefined() | ||
expect(getByTestId('carousel-nav-left')).toBeDefined() | ||
expect(getByTestId('carousel-nav-left')).toBeDefined() | ||
}) | ||
|
||
test('Carousel renders region/children without errors', () => { | ||
const CarouselWithPageProvider = withPageProvider(Carousel) | ||
const {getByTestId} = renderWithProviders( | ||
<CarouselWithPageProvider regions={[SAMPLE_REGION]} /> | ||
) | ||
|
||
expect(getByTestId('carousel')).toBeDefined() | ||
expect(getByTestId('carousel-container-items').childElementCount).toEqual(1) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/* | ||
* Copyright (c) 2023, Salesforce, Inc. | ||
* All rights reserved. | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
*/ | ||
import React from 'react' | ||
import {Page, Region} from 'commerce-sdk-react-preview/components' | ||
import SamplePage from './sample-page.json' | ||
import Carousel from '../../components/experience/carousel' | ||
|
||
const componentMapProxy = new Proxy( | ||
{}, | ||
{ | ||
// eslint-disable-next-line no-unused-vars | ||
get(_target, prop) { | ||
let componentClass | ||
switch (prop) { | ||
case 'commerce_assets.productTile': | ||
componentClass = ({id}) => ( | ||
<img src={`https://picsum.photos/seed/${id}/200/300`} /> | ||
) | ||
break | ||
case 'commerce_layouts.carousel': | ||
componentClass = Carousel | ||
break | ||
default: | ||
componentClass = (props) => ( | ||
<div style={{marginBottom: '10px'}}> | ||
<b>{props.typeId}</b> | ||
{props?.regions?.map((region) => ( | ||
<Region | ||
style={{margin: '0px 0px 5px 20px'}} | ||
key={region.id} | ||
region={region} | ||
/> | ||
))} | ||
</div> | ||
) | ||
} | ||
|
||
return componentClass | ||
} | ||
} | ||
) | ||
|
||
const PageViewer = () => { | ||
return <Page page={SamplePage} components={componentMapProxy} /> | ||
} | ||
|
||
export default PageViewer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A reminder that I understood we mentioned that we want to import these components directly in the template for now until we release the new commerce-sdk package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From the sounds of it, we'll be copying over those components from the library into the template, we'll have to update this import.