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:
@@ -29,3 +29,7 @@
|
||||
grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
.navigationButton {
|
||||
top: 30%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
23
components/Carousel/CarouselContent.tsx
Normal file
23
components/Carousel/CarouselContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
components/Carousel/CarouselContext.tsx
Normal file
17
components/Carousel/CarouselContext.tsx
Normal 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 }
|
||||
45
components/Carousel/CarouselDots.tsx
Normal file
45
components/Carousel/CarouselDots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
components/Carousel/CarouselItem.tsx
Normal file
22
components/Carousel/CarouselItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
components/Carousel/CarouselNavigation.tsx
Normal file
45
components/Carousel/CarouselNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
components/Carousel/carousel.module.css
Normal file
82
components/Carousel/carousel.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
108
components/Carousel/index.tsx
Normal file
108
components/Carousel/index.tsx
Normal 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 }
|
||||
34
components/Carousel/types.ts
Normal file
34
components/Carousel/types.ts
Normal 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
|
||||
}
|
||||
@@ -44,10 +44,6 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.card {
|
||||
max-width: 413px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
@@ -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
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user