-
Notifications
You must be signed in to change notification settings - Fork 142
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature: Page Designer Carousel Component (#977)
* Initial Implementation * Playing with indicator logic * Fix scroll display value value. * Clean up JSDoc * Add tests * Add logic to ensure controls are only shown when overflowing * Move Carousel into "layouts" * Update CHANGELOG.md * Fix module import * Update packages/template-retail-react-app/app/components/experience/layouts/carousel/index.jsx Co-authored-by: Adam Raya <[email protected]> * Remove testing page --------- Co-authored-by: Adam Raya <[email protected]>
- Loading branch information
Showing
7 changed files
with
250 additions
and
136 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
208 changes: 208 additions & 0 deletions
208
packages/template-retail-react-app/app/components/experience/layouts/carousel/index.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
/* | ||
* 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 '../../component' | ||
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 indicators/pips on "xs" screens. | ||
* @param {boolean} props.smCarouselIndicators - Show/Hide carousel indicators/pips on "sm" screens. | ||
* @param {boolean} props.mdCarouselIndicators - Show/Hide carousel indicators/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} - Carousel component. | ||
*/ | ||
export 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' | ||
}) | ||
}) | ||
|
||
// 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 browsers 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> | ||
|
||
{/* Button Controls */} | ||
<Fragment> | ||
<Box | ||
display={controlDisplay} | ||
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 |
40 changes: 40 additions & 0 deletions
40
packages/template-retail-react-app/app/components/experience/layouts/carousel/index.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 0 additions & 38 deletions
38
packages/template-retail-react-app/app/pages/page-viewer/index.jsx
This file was deleted.
Oops, something went wrong.
93 changes: 0 additions & 93 deletions
93
packages/template-retail-react-app/app/pages/page-viewer/utils.js
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.