Skip to content

Commit

Permalink
Base carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
trybick committed Jun 13, 2023
1 parent e29f7a2 commit 0acf19f
Show file tree
Hide file tree
Showing 7 changed files with 451 additions and 49 deletions.
2 changes: 1 addition & 1 deletion front/src/components/common/FollowButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const FollowButton = ({
<Button
colorScheme="cyan"
isLoading={isLoading}
leftIcon={<SmallAddIcon />}
leftIcon={<SmallAddIcon fontSize="22px" marginInlineEnd="-0.2rem" />}
onClick={onFollowShow}
variant="outline"
{...(unfollowedWidth && { minW: unfollowedWidth })}
Expand Down
352 changes: 352 additions & 0 deletions front/src/components/common/carousel/carousel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
/* eslint-disable react/prop-types */
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Flex, Progress, useMediaQuery, useTheme, VStack } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { motion, useAnimation, useMotionValue } from 'framer-motion';
import useBoundingRect from './useBoundingRect';
import { percentage } from './utils';

const MotionFlex = motion(Flex);

const transitionProps = {
stiffness: 400,
type: 'spring',
damping: 60,
mass: 3,
};

// Copied from https://codesandbox.io/s/focused-night-jc53r5
const Carousel = ({ children, gap, itemWidth }) => {
const [trackIsActive, setTrackIsActive] = useState(false);
const [multiplier, setMultiplier] = useState(0.35);
const [sliderWidth, setSliderWidth] = useState(0);
const [activeItem, setActiveItem] = useState(0);
const [constraint, setConstraint] = useState(0);

const initSliderWidth = useCallback(width => setSliderWidth(width), []);

const positions = useMemo(
() => children.map((_, index) => -Math.abs((itemWidth + gap) * index)),
[children, itemWidth, gap]
);

const { breakpoints } = useTheme();
const [isBetweenBaseAndMd] = useMediaQuery(
`(min-width: ${breakpoints.base}) and (max-width: ${breakpoints.md})`
);
const [isBetweenMdAndXl] = useMediaQuery(
`(min-width: ${breakpoints.md}) and (max-width: ${breakpoints.xl})`
);
const [isGreaterThanXL] = useMediaQuery(`(min-width: ${breakpoints.xl})`);

useEffect(() => {
if (isBetweenBaseAndMd) {
setMultiplier(0.65);
setConstraint(1);
}
if (isBetweenMdAndXl) {
setMultiplier(0.5);
setConstraint(2);
}
if (isGreaterThanXL) {
setMultiplier(0.35);
setConstraint(3);
}
}, [isBetweenBaseAndMd, isBetweenMdAndXl, isGreaterThanXL, sliderWidth, gap]);

const sliderProps = {
setTrackIsActive,
initSliderWidth,
setActiveItem,
activeItem,
constraint,
itemWidth,
positions,
gap,
};

const trackProps = {
setTrackIsActive,
trackIsActive,
setActiveItem,
sliderWidth,
activeItem,
constraint,
multiplier,
itemWidth,
positions,
gap,
};

const itemProps = {
setTrackIsActive,
trackIsActive,
setActiveItem,
activeItem,
constraint,
itemWidth,
positions,
gap,
};

return (
<Slider {...sliderProps}>
<Track {...trackProps}>
{children.map((child, index) => (
<Item {...itemProps} index={index} key={index}>
{child}
</Item>
))}
</Track>
</Slider>
);
};

const Slider = ({
setTrackIsActive,
initSliderWidth,
setActiveItem,
activeItem,
constraint,
itemWidth,
positions,
children,
gap,
}) => {
const [ref, { width }] = useBoundingRect();

useLayoutEffect(() => initSliderWidth(Math.round(width)), [width, initSliderWidth]);

const handleFocus = () => setTrackIsActive(true);

const handleDecrementClick = () => {
setTrackIsActive(true);
!(activeItem === positions.length - positions.length) && setActiveItem(prev => prev - 1);
};

const handleIncrementClick = () => {
setTrackIsActive(true);
!(activeItem === positions.length - constraint) && setActiveItem(prev => prev + 1);
};

return (
<>
<Box
ml={{ base: 0, md: `-${gap / 2}px` }}
overflow="hidden"
position="relative"
px={`${gap / 2}px`}
ref={ref}
w={{ base: '100%', md: `calc(100% + ${gap}px)` }}
>
{children}
</Box>

<Flex mt={`${gap / 2}px`} mx="auto" w={`${itemWidth}px`}>
<Button
minW={0}
mr={`${gap / 3}px`}
onClick={handleDecrementClick}
onFocus={handleFocus}
variant="link"
>
<ChevronLeftIcon boxSize={9} />
</Button>

<Progress
alignSelf="center"
borderRadius="2px"
flex={1}
h="3px"
sx={{
'> div': {
backgroundColor: 'gray.400',
},
}}
value={percentage(activeItem, positions.length - constraint)}
/>

<Button
minW={0}
ml={`${gap / 3}px`}
onClick={handleIncrementClick}
onFocus={handleFocus}
variant="link"
zIndex={2}
>
<ChevronRightIcon boxSize={9} />
</Button>
</Flex>
</>
);
};

const Track = ({
setTrackIsActive,
trackIsActive,
setActiveItem,
activeItem,
constraint,
multiplier,
itemWidth,
positions,
children,
}) => {
const [dragStartPosition, setDragStartPosition] = useState(0);
const controls = useAnimation();
const x = useMotionValue(0);
const node = useRef(null);

const handleDragStart = () => setDragStartPosition(positions[activeItem]);

const handleDragEnd = (_, info) => {
const distance = info.offset.x;
const velocity = info.velocity.x * multiplier;
const direction = velocity < 0 || distance < 0 ? 1 : -1;

const extrapolatedPosition =
dragStartPosition +
(direction === 1 ? Math.min(velocity, distance) : Math.max(velocity, distance));

const closestPosition = positions.reduce((prev, curr) => {
return Math.abs(curr - extrapolatedPosition) < Math.abs(prev - extrapolatedPosition)
? curr
: prev;
}, 0);

if (!(closestPosition < positions[positions.length - constraint])) {
setActiveItem(positions.indexOf(closestPosition));
controls.start({
x: closestPosition,
transition: {
velocity: info.velocity.x,
...transitionProps,
},
});
} else {
setActiveItem(positions.length - constraint);
controls.start({
x: positions[positions.length - constraint],
transition: {
velocity: info.velocity.x,
...transitionProps,
},
});
}
};

const handleResize = useCallback(
() =>
controls.start({
x: positions[activeItem],
transition: {
...transitionProps,
},
}),
[activeItem, controls, positions]
);

const handleClick = useCallback(
event =>
node.current.contains(event.target) ? setTrackIsActive(true) : setTrackIsActive(false),
[setTrackIsActive]
);

const handleKeyDown = useCallback(
event => {
if (trackIsActive) {
if (activeItem < positions.length - constraint) {
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
event.preventDefault();
setActiveItem(prev => prev + 1);
}
}
if (activeItem > positions.length - positions.length) {
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
event.preventDefault();
setActiveItem(prev => prev - 1);
}
}
}
},
[trackIsActive, setActiveItem, activeItem, constraint, positions.length]
);

useEffect(() => {
handleResize(positions);

document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClick);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClick);
};
}, [handleClick, handleResize, handleKeyDown, positions]);

return (
<>
{itemWidth && (
<VStack alignItems="stretch" ref={node} spacing={5}>
<MotionFlex
_active={{ cursor: 'grabbing' }}
animate={controls}
cursor="grab"
drag="x"
dragConstraints={node}
flexWrap="nowrap"
minWidth="min-content"
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
style={{ x }}
>
{children}
</MotionFlex>
</VStack>
)}
</>
);
};

const Item = ({
setTrackIsActive,
setActiveItem,
activeItem,
constraint,
itemWidth,
positions,
children,
index,
gap,
}) => {
const [userDidTab, setUserDidTab] = useState(false);

const handleFocus = () => setTrackIsActive(true);

const handleBlur = () => {
userDidTab && index + 1 === positions.length && setTrackIsActive(false);
setUserDidTab(false);
};

const handleKeyUp = event =>
event.key === 'Tab' && !(activeItem === positions.length - constraint) && setActiveItem(index);

const handleKeyDown = event => event.key === 'Tab' && setUserDidTab(true);

return (
<Flex
_notLast={{
mr: `${gap}px`,
}}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
py="4px"
w={`${itemWidth}px`}
>
{children}
</Flex>
);
};

export default Carousel;
Loading

0 comments on commit 0acf19f

Please sign in to comment.