'use client'; import React, { forwardRef, useCallback, useEffect, useId, useMemo, useState } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { Surface, cn, surfaceClasses, useEffectiveSurface } from '../common'; import { useReducedMotion } from '../hooks/useReducedMotion'; type Orientation = 'horizontal' | 'vertical'; export interface PixelCarouselProps extends React.HTMLAttributes { /** * Full embla options surface (see embla-carousel docs). Common subset: * `loop`, `align`, `slidesToScroll`, `startIndex`, `dragFree`, * `containScroll`, `inViewThreshold`. `axis` is set internally from * `orientation`. */ opts?: Omit; /** Optional embla plugins (autoplay, autoScroll, etc.). */ plugins?: EmblaPluginType[]; /** Receives the embla API once ready; called again with `undefined` on unmount. */ setApi?: (api: EmblaCarouselType | undefined) => void; orientation?: Orientation; showArrows?: boolean; showDots?: boolean; surface?: Surface; /** Accessible name for the carousel region (required for landmark navigation). */ 'aria-label'?: string; children: React.ReactNode; } interface PixelCarouselItemProps extends React.HTMLAttributes { children: React.ReactNode; } interface CarouselItemCtx { index: number; total: number; } const CarouselItemContext = React.createContext(null); const PixelCarouselItem = forwardRef( function PixelCarouselItem({ children, className, ...rest }, ref) { const ctx = React.useContext(CarouselItemContext); const ariaLabel = ctx ? `Slide ${ctx.index + 1} of ${ctx.total}` : undefined; return (
{children}
); }, ); PixelCarouselItem.displayName = 'PixelCarousel.Item'; const PixelCarouselRoot = forwardRef(function PixelCarousel( { opts, plugins, setApi, orientation = 'horizontal', showArrows = true, showDots = false, surface: surfaceProp, children, className, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const isVertical = orientation === 'vertical'; const carouselId = useId(); const reducedMotion = useReducedMotion(); const emblaOptions = useMemo( () => ({ loop: opts?.loop ?? false, align: opts?.align ?? 'start', slidesToScroll: opts?.slidesToScroll ?? 1, ...opts, axis: isVertical ? 'y' : 'x', // Respect prefers-reduced-motion by zeroing out embla's drag/scroll dur. duration: reducedMotion ? 0 : (opts as { duration?: number } | undefined)?.duration ?? 25, }), [opts, isVertical, reducedMotion], ); const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions, plugins); const [selectedIndex, setSelectedIndex] = useState(0); const [scrollSnaps, setScrollSnaps] = useState([]); const [canPrev, setCanPrev] = useState(false); const [canNext, setCanNext] = useState(false); const onSelect = useCallback(() => { if (!emblaApi) return; setSelectedIndex(emblaApi.selectedScrollSnap()); setCanPrev(emblaApi.canScrollPrev()); setCanNext(emblaApi.canScrollNext()); }, [emblaApi]); useEffect(() => { if (!emblaApi) return; setScrollSnaps(emblaApi.scrollSnapList()); onSelect(); emblaApi.on('select', onSelect); emblaApi.on('reInit', onSelect); return () => { emblaApi.off('select', onSelect); emblaApi.off('reInit', onSelect); }; }, [emblaApi, onSelect]); // Expose embla API to the parent (shadcn-style escape hatch). useEffect(() => { if (!setApi) return; setApi(emblaApi ?? undefined); return () => setApi(undefined); }, [emblaApi, setApi]); const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]); const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]); const scrollTo = useCallback((i: number) => emblaApi?.scrollTo(i), [emblaApi]); const onKeyDown: React.KeyboardEventHandler = (e) => { if (isVertical) { if (e.key === 'ArrowUp') { e.preventDefault(); scrollPrev(); } else if (e.key === 'ArrowDown') { e.preventDefault(); scrollNext(); } } else { if (e.key === 'ArrowLeft') { e.preventDefault(); scrollPrev(); } else if (e.key === 'ArrowRight') { e.preventDefault(); scrollNext(); } } }; // Derive slide count from children for dots fallback when api hasn't reported yet. const childArray = React.Children.toArray(children).filter(React.isValidElement); const dotCount = scrollSnaps.length > 0 ? scrollSnaps.length : childArray.length; const total = childArray.length; // Wrap each Item child in an index/total context so its aria-label can read // "Slide N of M". Done via a Provider per child rather than cloneElement to // support user-defined wrapper components around Items. const wrappedChildren = childArray.map((child, i) => ( {child} )); return (
{/* Dedicated live region: announces only the slide index, not the slide content itself (APG recommendation). */} {total > 0 ? `Slide ${selectedIndex + 1} of ${total}` : ''}
{wrappedChildren}
{showArrows && ( <> )} {showDots && dotCount > 0 && (
{Array.from({ length: dotCount }).map((_, i) => { const active = i === selectedIndex; return (
)}
); }); PixelCarouselRoot.displayName = 'PixelCarousel'; type PixelCarouselNamespace = typeof PixelCarouselRoot & { Item: typeof PixelCarouselItem; }; const PixelCarouselBase = PixelCarouselRoot as PixelCarouselNamespace; PixelCarouselBase.Item = PixelCarouselItem; export const PixelCarousel = PixelCarouselBase; export { PixelCarouselItem };