diff --git a/packages/dashboard-v2/src/components/Slider/Bullets.js b/packages/dashboard-v2/src/components/Slider/Bullets.js new file mode 100644 index 00000000..ab33f64a --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Bullets.js @@ -0,0 +1,28 @@ +import PropTypes from "prop-types"; + +export default function Bullets({ visibleSlides, activeIndex, allSlides, changeSlide }) { + if (allSlides <= visibleSlides) { + return null; + } + + return ( +
+ {Array(allSlides - visibleSlides + 1) + .fill(null) + .map((_, i) => ( + changeSlide(i)} + /> + ))} +
+ ); +} + +Bullets.propTypes = { + allSlides: PropTypes.number.isRequired, + activeIndex: PropTypes.number.isRequired, + visibleSlides: PropTypes.number.isRequired, + changeSlide: PropTypes.func.isRequired, +}; diff --git a/packages/dashboard-v2/src/components/Slider/Slide.js b/packages/dashboard-v2/src/components/Slider/Slide.js new file mode 100644 index 00000000..4f700502 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Slide.js @@ -0,0 +1,12 @@ +import styled from "styled-components"; +import PropTypes from "prop-types"; + +const Slide = styled.div.attrs(({ isVisible }) => ({ + className: `slider-slide transition-opacity ${isVisible ? "" : "opacity-50 cursor-pointer"}`, +}))``; + +Slide.propTypes = { + isVisible: PropTypes.bool.isRequired, +}; + +export default Slide; diff --git a/packages/dashboard-v2/src/components/Slider/Slider.js b/packages/dashboard-v2/src/components/Slider/Slider.js new file mode 100644 index 00000000..bb4654b2 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Slider.js @@ -0,0 +1,105 @@ +import * as React from "react"; +import PropTypes from "prop-types"; +import styled, { css } from "styled-components"; +import theme from "../../lib/theme"; + +import useActiveBreakpoint from "./useActiveBreakpoint"; +import Bullets from "./Bullets"; +import Slide from "./Slide"; + +const Container = styled.div.attrs({ + className: "slider w-full", +})``; + +/** + * Styles applied to the movable element when the number of slide elements + * exceeds the number of visible slides for the current breakpoint + * */ +const scrollableStyles = css` + ${({ $allSlides, $visibleSlides, $activeIndex }) => ` + transform: translateX(calc(-1 * ${$activeIndex} * ((100% + 1rem) / ${$visibleSlides}))); + grid-template-columns: repeat(${$allSlides}, calc((100% - ${$visibleSlides - 1}rem) / ${$visibleSlides})); + `} +`; + +const Scroller = styled.div.attrs({ + className: "slider-scroller grid gap-4 transition-transform", +})` + ${({ $scrollable }) => ($scrollable ? scrollableStyles : "")} +`; + +const Slider = ({ slides, breakpoints }) => { + const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints); + const [activeIndex, setActiveIndex] = React.useState(0); + const changeSlide = React.useCallback( + (index) => { + setActiveIndex(Math.min(index, slides.length - visibleSlides)); // Don't let it scroll too far + }, + [slides, visibleSlides, setActiveIndex] + ); + + return ( + + + {slides.map((slide, i) => { + const isVisible = i >= activeIndex && i < activeIndex + visibleSlides; + + return ( +
+ changeSlide(i) : null} + > + {slide} + +
+ ); + })} +
+ +
+ ); +}; + +Slider.propTypes = { + slides: PropTypes.arrayOf(PropTypes.node.isRequired), + breakpoints: PropTypes.arrayOf( + PropTypes.shape({ + minWidth: PropTypes.number.isRequired, + visibleSlides: PropTypes.number.isRequired, + }) + ), +}; + +Slider.defaultProps = { + breakpoints: [ + { + minWidth: parseInt(theme.screens.xl), + scrollable: true, + visibleSlides: 3, + }, + { + minWidth: parseInt(theme.screens.md, 10), + scrollable: true, + visibleSlides: 2, + }, + { + // For the smallest screens, we won't scroll but instead stack the slides vertically. + minWidth: -Infinity, + scrollable: false, + visibleSlides: 1, + }, + ], +}; + +export default Slider; diff --git a/packages/dashboard-v2/src/components/Slider/index.js b/packages/dashboard-v2/src/components/Slider/index.js new file mode 100644 index 00000000..a5890919 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/index.js @@ -0,0 +1 @@ +export * from "./Slider"; diff --git a/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js new file mode 100644 index 00000000..e35e1f19 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js @@ -0,0 +1,23 @@ +import { useEffect, useMemo, useCallback, useState } from "react"; +import { useWindowSize } from "react-use"; + +export default function useActiveBreakpoint(breakpoints) { + const { width: windowWidth } = useWindowSize(); + // Since our breakpoints are setup with min-width rule, we need to sort them from largest to smallest + const monitoredBreakpoints = useMemo( + () => breakpoints.slice().sort(({ minWidth: widthA }, { minWidth: widthB }) => widthB - widthA), + [breakpoints] + ); + const findActiveBreakpoint = useCallback( + () => monitoredBreakpoints.find((breakpoint) => windowWidth >= breakpoint.minWidth), + [monitoredBreakpoints, windowWidth] + ); + + const [activeBreakpoint, setActiveBreakpoint] = useState(findActiveBreakpoint()); + + useEffect(() => { + setActiveBreakpoint(findActiveBreakpoint()); + }, [findActiveBreakpoint]); + + return activeBreakpoint; +}