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
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-02-14 10:53:14 +00:00
parent 224a41ec74
commit 38cce4b136
14 changed files with 441 additions and 23 deletions

View File

@@ -29,3 +29,7 @@
grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3);
}
}
.navigationButton {
top: 30%;
}

View File

@@ -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 */}
<div className={styles.cardsContainer}>
{filteredCards.map((card) => (
<Carousel>
<Carousel.Content>
{filteredCards.map((card, index) => (
<Carousel.Item key={`${card.heading}-${index}`}>
<ContentCard
key={card.heading}
heading={card.heading}
image={card.image}
bodyText={card.bodyText}
promoText={card.promoText}
link={card.link}
/>
</Carousel.Item>
))}
</div>
</Carousel.Content>
<Carousel.Previous className={styles.navigationButton} />
<Carousel.Next className={styles.navigationButton} />
<Carousel.Dots />
</Carousel>
<SectionLink link={link} variant="mobile" />
</SectionContainer>
)

View File

@@ -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<HTMLDivElement>) {
const { carouselRef } = useCarousel()
return (
<div ref={carouselRef} className={styles.viewport}>
<div className={cx(styles.container, className)} {...props}>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
"use client"
import { createContext, useContext } from "react"
import type { CarouselContextProps } from "./types"
const CarouselContext = createContext<CarouselContextProps | null>(null)
export function useCarousel() {
const context = useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
export { CarouselContext }

View File

@@ -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<number[]>([])
// 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 (
<div className={cx(styles.dots, className)}>
{scrollSnaps.map((_, index) => (
<div
key={index}
className={styles.dot}
data-active={index === selectedIndex}
aria-hidden="true"
/>
))}
</div>
)
}

View File

@@ -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<HTMLDivElement>) {
return (
<div
role="group"
aria-roledescription="slide"
className={cx(styles.item, className)}
{...props}
>
{children}
</div>
)
}

View File

@@ -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 (
<button
className={cx(styles.button, styles.buttonPrev, className)}
onClick={scrollPrev}
aria-label="Previous slide"
{...props}
>
<ArrowRightIcon color="burgundy" className={styles.prevIcon} />
</button>
)
}
export function CarouselNext({ className, ...props }: CarouselButtonProps) {
const { scrollNext, canScrollNext } = useCarousel()
if (!canScrollNext()) return null
return (
<button
className={cx(styles.button, styles.buttonNext, className)}
onClick={scrollNext}
aria-label="Next slide"
{...props}
>
<ArrowRightIcon color="burgundy" />
</button>
)
}

View File

@@ -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);
}
}

View File

@@ -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<HTMLDivElement>) {
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 (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
plugins,
setApi,
children,
scrollPrev,
scrollNext,
canScrollPrev: api?.canScrollPrev.bind(api) ?? (() => false),
canScrollNext: api?.canScrollNext.bind(api) ?? (() => false),
selectedIndex,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cx(styles.root, className)}
role="region"
aria-roledescription="carousel"
>
{children}
</div>
</CarouselContext.Provider>
)
}
Carousel.Content = CarouselContent
Carousel.Item = CarouselItem
Carousel.Next = CarouselNext
Carousel.Previous = CarouselPrevious
Carousel.Dots = CarouselDots
export { Carousel }
export type { CarouselApi }

View File

@@ -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<typeof useEmblaCarousel>
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<CarouselProps, "className"> {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: () => boolean
canScrollNext: () => boolean
selectedIndex: number
}
export interface CarouselButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
className?: string
}

View File

@@ -44,10 +44,6 @@
}
@media (min-width: 768px) {
.card {
max-width: 413px;
}
.content {
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2) 0;
}

View File

@@ -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);
}
}

30
package-lock.json generated
View File

@@ -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",

View File

@@ -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",