From 38cce4b13642dd1b92ac60a7136dcca6ed126fa3 Mon Sep 17 00:00:00 2001 From: "Chuma Mcphoy (We Ahead)" Date: Fri, 14 Feb 2025 10:53:14 +0000 Subject: [PATCH] Merged in feat/SW-1542-carousel-functionality (pull request #1311) feat(SW-1542): Carousel component * feat(SW-1542): add Embla Carousel component and use in CarouselCards * fix(SW-1542): remove max-width constraint for card on ipad * fix(SW-1542): Add padding to start page content container * refactor(SW-1542): Improve Embla Carousel type imports * refactor(SW-1542): Remove unnecessary carousel wrapper div * refactor(SW-1542): Modularize Carousel component structure * refactor(SW-1542): Remove carousel dots display * feat(SW-1542): Add carousel dots navigation * refactor(SW-1542): Update Carousel component styling and types * refactor(SW-1542): Remove uneeded useCallback from Carousel navigation methods * refactor(SW-1542): Modify CarouselContextProps type to exclude className * refactor(SW-1542): Optimize React imports in Carousel components * refactor(SW-1542): Consolidate Carousel component and remove CarouselRoot * refactor(SW-1542): Update Carousel navigation methods to use function-based scroll checks * refactor(SW-1542): Add explicit children prop support to CarouselContent component * refactor(SW-1542): Add children prop support to CarouselItem component Approved-by: Christian Andolf --- .../CarouselCards/carouselCards.module.css | 4 + components/Blocks/CarouselCards/index.tsx | 32 +++--- components/Carousel/CarouselContent.tsx | 23 ++++ components/Carousel/CarouselContext.tsx | 17 +++ components/Carousel/CarouselDots.tsx | 45 ++++++++ components/Carousel/CarouselItem.tsx | 22 ++++ components/Carousel/CarouselNavigation.tsx | 45 ++++++++ components/Carousel/carousel.module.css | 82 +++++++++++++ components/Carousel/index.tsx | 108 ++++++++++++++++++ components/Carousel/types.ts | 34 ++++++ components/ContentCard/contentCard.module.css | 4 - .../StartPage/startPage.module.css | 16 ++- package-lock.json | 30 +++++ package.json | 2 + 14 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 components/Carousel/CarouselContent.tsx create mode 100644 components/Carousel/CarouselContext.tsx create mode 100644 components/Carousel/CarouselDots.tsx create mode 100644 components/Carousel/CarouselItem.tsx create mode 100644 components/Carousel/CarouselNavigation.tsx create mode 100644 components/Carousel/carousel.module.css create mode 100644 components/Carousel/index.tsx create mode 100644 components/Carousel/types.ts diff --git a/components/Blocks/CarouselCards/carouselCards.module.css b/components/Blocks/CarouselCards/carouselCards.module.css index 4ea1a4c2a..0e4d728c6 100644 --- a/components/Blocks/CarouselCards/carouselCards.module.css +++ b/components/Blocks/CarouselCards/carouselCards.module.css @@ -29,3 +29,7 @@ grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3); } } + +.navigationButton { + top: 30%; +} diff --git a/components/Blocks/CarouselCards/index.tsx b/components/Blocks/CarouselCards/index.tsx index 459169958..f19a615c1 100644 --- a/components/Blocks/CarouselCards/index.tsx +++ b/components/Blocks/CarouselCards/index.tsx @@ -2,6 +2,7 @@ import { useState } from "react" +import { Carousel } from "@/components/Carousel" import ContentCard from "@/components/ContentCard" import SectionContainer from "@/components/Section/Container" import SectionHeader from "@/components/Section/Header" @@ -43,19 +44,24 @@ export default function CarouselCards({ carousel_cards }: CarouselCardsProps) { onFilterSelect={setActiveFilter} /> )} - {/* Carousel functionality will go here */} -
- {filteredCards.map((card) => ( - - ))} -
+ + + {filteredCards.map((card, index) => ( + + + + ))} + + + + + ) diff --git a/components/Carousel/CarouselContent.tsx b/components/Carousel/CarouselContent.tsx new file mode 100644 index 000000000..2fff8c089 --- /dev/null +++ b/components/Carousel/CarouselContent.tsx @@ -0,0 +1,23 @@ +"use client" + +import { cx } from "class-variance-authority" + +import { useCarousel } from "./CarouselContext" + +import styles from "./carousel.module.css" + +export function CarouselContent({ + className, + children, + ...props +}: React.HTMLAttributes) { + const { carouselRef } = useCarousel() + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/components/Carousel/CarouselContext.tsx b/components/Carousel/CarouselContext.tsx new file mode 100644 index 000000000..a37827d96 --- /dev/null +++ b/components/Carousel/CarouselContext.tsx @@ -0,0 +1,17 @@ +"use client" + +import { createContext, useContext } from "react" + +import type { CarouselContextProps } from "./types" + +const CarouselContext = createContext(null) + +export function useCarousel() { + const context = useContext(CarouselContext) + if (!context) { + throw new Error("useCarousel must be used within a ") + } + return context +} + +export { CarouselContext } diff --git a/components/Carousel/CarouselDots.tsx b/components/Carousel/CarouselDots.tsx new file mode 100644 index 000000000..2db1b35c0 --- /dev/null +++ b/components/Carousel/CarouselDots.tsx @@ -0,0 +1,45 @@ +"use client" + +import { cx } from "class-variance-authority" +import { useEffect, useState } from "react" + +import { useCarousel } from "./CarouselContext" + +import styles from "./carousel.module.css" + +export function CarouselDots({ className }: { className?: string }) { + const { selectedIndex, api } = useCarousel() + const [scrollSnaps, setScrollSnaps] = useState([]) + + // Update scroll snaps when the carousel is initialized or viewport changes + useEffect(() => { + if (!api) return + + const onInit = () => { + setScrollSnaps(api.scrollSnapList()) + } + + onInit() + api.on("reInit", onInit) + + return () => { + api.off("reInit", onInit) + } + }, [api]) + + // Don't render dots if we have 1 or fewer scroll positions + if (scrollSnaps.length <= 1) return null + + return ( +
+ {scrollSnaps.map((_, index) => ( + + ) +} diff --git a/components/Carousel/CarouselItem.tsx b/components/Carousel/CarouselItem.tsx new file mode 100644 index 000000000..f15711833 --- /dev/null +++ b/components/Carousel/CarouselItem.tsx @@ -0,0 +1,22 @@ +"use client" + +import { cx } from "class-variance-authority" + +import styles from "./carousel.module.css" + +export function CarouselItem({ + className, + children, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ) +} diff --git a/components/Carousel/CarouselNavigation.tsx b/components/Carousel/CarouselNavigation.tsx new file mode 100644 index 000000000..3f8566723 --- /dev/null +++ b/components/Carousel/CarouselNavigation.tsx @@ -0,0 +1,45 @@ +"use client" + +import { cx } from "class-variance-authority" + +import { ArrowRightIcon } from "@/components/Icons" + +import { useCarousel } from "./CarouselContext" + +import styles from "./carousel.module.css" + +import type { CarouselButtonProps } from "./types" + +export function CarouselPrevious({ className, ...props }: CarouselButtonProps) { + const { scrollPrev, canScrollPrev } = useCarousel() + + if (!canScrollPrev()) return null + + return ( + + ) +} + +export function CarouselNext({ className, ...props }: CarouselButtonProps) { + const { scrollNext, canScrollNext } = useCarousel() + + if (!canScrollNext()) return null + + return ( + + ) +} diff --git a/components/Carousel/carousel.module.css b/components/Carousel/carousel.module.css new file mode 100644 index 000000000..5aeabf62c --- /dev/null +++ b/components/Carousel/carousel.module.css @@ -0,0 +1,82 @@ +.root { + position: relative; +} + +.viewport { + overflow: hidden; +} + +.container { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 100%; + gap: var(--Spacing-x3); +} + +.item { + min-width: 0; +} + +.button { + display: none; + align-items: center; + justify-content: center; + position: absolute; + top: 50%; + transform: translateY(-50%); + background: var(--Base-Surface-Primary-light-Normal); + box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1); + border: none; + border-radius: 50%; + padding: var(--Spacing-x1); + cursor: pointer; + z-index: 1; +} + +.buttonPrev { + left: calc(-1 * var(--Spacing-x3)); +} + +.buttonNext { + right: calc(-1 * var(--Spacing-x3)); +} + +.prevIcon { + transform: rotate(180deg); +} + +.dots { + display: flex; + justify-content: center; + gap: var(--Spacing-x1); + margin-top: var(--Spacing-x3); +} + +.dot { + height: 8px; + width: 8px; + border-radius: var(--Corner-radius-Large); + background: var(--UI-Grey-40); + transition: width 0.3s ease; +} + +.dot[data-active="true"] { + width: 22px; + background: var(--UI-Text-Medium-contrast); +} + +@media (min-width: 768px) { + .container { + grid-auto-columns: calc((100% - var(--Spacing-x3)) / 2); + } + + .button { + display: flex; + } +} + +@media (min-width: 1024px) { + .container { + grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3); + } +} diff --git a/components/Carousel/index.tsx b/components/Carousel/index.tsx new file mode 100644 index 000000000..5c9b38c61 --- /dev/null +++ b/components/Carousel/index.tsx @@ -0,0 +1,108 @@ +"use client" + +import { cx } from "class-variance-authority" +import useEmblaCarousel from "embla-carousel-react" +import { useCallback, useEffect, useState } from "react" + +import { CarouselContent } from "./CarouselContent" +import { CarouselContext } from "./CarouselContext" +import { CarouselDots } from "./CarouselDots" +import { CarouselItem } from "./CarouselItem" +import { CarouselNext, CarouselPrevious } from "./CarouselNavigation" + +import styles from "./carousel.module.css" + +import type { CarouselApi, CarouselProps } from "./types" + +function Carousel({ + opts, + setApi, + plugins, + className, + children, +}: CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: "x", + }, + plugins + ) + const [selectedIndex, setSelectedIndex] = useState(0) + + const onSelect = useCallback((api: CarouselApi) => { + if (!api) return + setSelectedIndex(api.selectedScrollSnap()) + }, []) + + function scrollPrev() { + api?.scrollPrev() + } + + function scrollNext() { + api?.scrollNext() + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + } + + useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + useEffect(() => { + if (!api) return + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + false), + canScrollNext: api?.canScrollNext.bind(api) ?? (() => false), + selectedIndex, + }} + > +
+ {children} +
+
+ ) +} + +Carousel.Content = CarouselContent +Carousel.Item = CarouselItem +Carousel.Next = CarouselNext +Carousel.Previous = CarouselPrevious +Carousel.Dots = CarouselDots + +export { Carousel } +export type { CarouselApi } diff --git a/components/Carousel/types.ts b/components/Carousel/types.ts new file mode 100644 index 000000000..5323a9b3e --- /dev/null +++ b/components/Carousel/types.ts @@ -0,0 +1,34 @@ +/** + * Must be imported from the core package. + * @see https://www.embla-carousel.com/api/methods/#typescript + */ +import type { EmblaCarouselType } from "embla-carousel" +import type useEmblaCarousel from "embla-carousel-react" +import type { PropsWithChildren } from "react" + +export type CarouselApi = EmblaCarouselType +type UseCarouselParameters = Parameters +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +export interface CarouselProps extends PropsWithChildren { + opts?: CarouselOptions + plugins?: CarouselPlugin + setApi?: (api: CarouselApi) => void + className?: string +} + +export interface CarouselContextProps extends Omit { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: () => boolean + canScrollNext: () => boolean + selectedIndex: number +} + +export interface CarouselButtonProps + extends React.ButtonHTMLAttributes { + className?: string +} diff --git a/components/ContentCard/contentCard.module.css b/components/ContentCard/contentCard.module.css index 0a82585af..db568787c 100644 --- a/components/ContentCard/contentCard.module.css +++ b/components/ContentCard/contentCard.module.css @@ -44,10 +44,6 @@ } @media (min-width: 768px) { - .card { - max-width: 413px; - } - .content { padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2) 0; } diff --git a/components/ContentType/StartPage/startPage.module.css b/components/ContentType/StartPage/startPage.module.css index d73244aa3..d42cd4fe0 100644 --- a/components/ContentType/StartPage/startPage.module.css +++ b/components/ContentType/StartPage/startPage.module.css @@ -49,12 +49,6 @@ background-color: var(--Base-Surface-Primary-light-Normal); } -@media screen and (min-width: 768px) { - .main { - gap: calc(var(--Spacing-x5) * 3); - } -} - .section:empty { display: none; } @@ -65,3 +59,13 @@ max-width: var(--max-width-content); width: 100%; } + +@media screen and (min-width: 768px) { + .main { + gap: calc(var(--Spacing-x5) * 3); + } + .section { + padding-left: var(--Spacing-x5); + padding-right: var(--Spacing-x5); + } +} diff --git a/package-lock.json b/package-lock.json index b49db9d7b..2c32c45d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,8 @@ "dayjs": "^1.11.10", "deepmerge": "^4.3.1", "downshift": "^9.0.8", + "embla-carousel": "^8.5.2", + "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "fetch-retry": "^6.0.0", "framer-motion": "^11.3.28", @@ -11849,6 +11851,34 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.52.tgz", "integrity": "sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ==" }, + "node_modules/embla-carousel": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.2.tgz", + "integrity": "sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.2.tgz", + "integrity": "sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.5.2", + "embla-carousel-reactive-utils": "8.5.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.2.tgz", + "integrity": "sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.2" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", diff --git a/package.json b/package.json index d80694fad..d93018dc5 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "dayjs": "^1.11.10", "deepmerge": "^4.3.1", "downshift": "^9.0.8", + "embla-carousel": "^8.5.2", + "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "fetch-retry": "^6.0.0", "framer-motion": "^11.3.28",