// // Copyright 2026 DXOS.org // import { useArrowNavigationGroup } from '@fluentui/react-tabster'; import { createContext } from '@radix-ui/react-context'; import React, { Children, type KeyboardEvent, type PropsWithChildren, type ReactNode, type TransitionEvent, cloneElement, isValidElement, useCallback, useEffect, useRef, useState, } from 'react'; import { mx } from '@dxos/ui-theme'; import { useTranslation } from '../../primitives'; import { translationKey } from '../../translations'; import { type ThemedClassName, composable, composableProps } from '../../util'; import { IconButton } from '../Button'; import { type MediaKind, MediaPlayer } from '../MediaPlayer'; // TODO(burdon): Controller. // // Context // /** Slide change behaviour: `none` hard-swaps the active slide, `slide` animates a horizontal track. */ export type CarouselTransition = 'none' | 'slide'; type CarouselContextValue = { index: number; count: number; transition: CarouselTransition; /** When `transition === 'slide'`, wrap-around advances continue in the same direction (clone + snap). */ continuous: boolean; /** * Track position in slide units. Equals `index` except mid-wrap, when it points at a clone cell * (`count` = trailing clone of the first slide, `-1` = leading clone of the last) so the track keeps * moving in the travel direction. Settled back to the real index once the transition ends. */ offset: number; /** Whether the track transform should animate; disabled for the instantaneous post-wrap snap. */ animate: boolean; /** Snap a clone cell back to its real slide once the wrap transition completes. */ settle: () => void; setIndex: (index: number) => void; next: () => void; prev: () => void; }; const CAROUSEL_NAME = 'Carousel'; const [CarouselProvider, useCarouselContext] = createContext(CAROUSEL_NAME); /** Returns the current carousel state. Must be used within {@link Carousel.Root}. */ export const useCarousel = (): CarouselContextValue => useCarouselContext('useCarousel'); // // Root // export type CarouselRootProps = PropsWithChildren<{ /** Total number of slides; drives auto-advance and indicator counts. */ count: number; /** * Auto-advance interval in milliseconds. A positive value advances slides on its own until the user * interacts with a control; omit (or `0`) to disable. */ autoAdvance?: number; defaultIndex?: number; /** Slide change behaviour. Defaults to `none` (hard swap); `slide` animates a horizontal track. */ transition?: CarouselTransition; /** * Wrap-around in the same travel direction (last → first slides forward, first → last slides back). * Only applies when `transition === 'slide'`; otherwise wrap is an instant index change. */ continuous?: boolean; }>; const CarouselRoot = ({ children, count, autoAdvance = 0, defaultIndex = 0, transition = 'none', continuous = false, }: CarouselRootProps) => { const [index, setIndexState] = useState(defaultIndex); const [offset, setOffset] = useState(defaultIndex); const [animate, setAnimate] = useState(true); const [autoEnabled, setAutoEnabled] = useState(autoAdvance > 0); // Continuous wrap is only meaningful for the animated track with more than one slide. const wraps = continuous && transition === 'slide' && count > 1; // Latest index without re-creating the advance/auto-advance callbacks on every change. const indexRef = useRef(index); useEffect(() => { indexRef.current = index; }, [index]); // Reset to first slide if the slide count shrinks below the current index. useEffect(() => { if (index >= count) { setIndexState(0); setOffset(0); } }, [count, index]); // Step one slide in `delta` direction. `stopAuto` halts auto-advance on user interaction. In wrap // mode an edge step targets a clone cell (offset `count` / `-1`) so the track travels in `delta`'s // direction; `settle` snaps it back to the real index after the transition. const advance = useCallback( (delta: 1 | -1, stopAuto: boolean) => { if (stopAuto) { setAutoEnabled(false); } const current = indexRef.current; const nextIndex = (current + delta + count) % count; setAnimate(true); if (wraps && delta === 1 && current === count - 1) { setOffset(count); } else if (wraps && delta === -1 && current === 0) { setOffset(-1); } else { setOffset(nextIndex); } setIndexState(nextIndex); }, [count, wraps], ); // Auto-advance — stops permanently once the user interacts with any control. useEffect(() => { if (!autoEnabled || count <= 1 || autoAdvance <= 0) { return; } const handle = setInterval(() => advance(1, false), autoAdvance); return () => clearInterval(handle); }, [autoEnabled, count, autoAdvance, advance]); const setIndex = useCallback((next: number) => { setAutoEnabled(false); setAnimate(true); setOffset(next); setIndexState(next); }, []); const next = useCallback(() => advance(1, true), [advance]); const prev = useCallback(() => advance(-1, true), [advance]); // Once the wrap transition lands on a clone, jump (without animating) to the matching real slide. const settle = useCallback(() => { if (offset === count) { setAnimate(false); setOffset(0); } else if (offset === -1) { setAnimate(false); setOffset(count - 1); } }, [offset, count]); if (count === 0) { return null; } return ( {children} ); }; CarouselRoot.displayName = 'Carousel.Root'; // // Content // export type CarouselContentProps = ThemedClassName>; const CarouselContent = composable(({ children, ...props }, forwardedRef) => ( // Rows are `[1fr, auto]`: row 1 (Previous|Viewport|Next) stretches when the parent // gives the carousel a definite height, and row 2 (Indicators / Caption) sticks to // its content height. With no parent height constraint, the `1fr` row simply tracks // row-1 content — preserving the existing aspect-video behaviour for unbounded use. // TODO(burdon): Move to Carousel.theme.ts
{children}
)); CarouselContent.displayName = 'Carousel.Content'; // // Viewport // export type CarouselViewportProps = ThemedClassName>; const CarouselViewport = ({ children, classNames }: CarouselViewportProps) => { const { t } = useTranslation(translationKey); const { count, transition, continuous, offset, animate, settle, next, prev } = useCarousel(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (count <= 1) { return; } if (event.key === 'ArrowLeft') { event.preventDefault(); prev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); next(); } }, [count, next, prev], ); // In continuous mode bracket the track with clones — last before first, first after last — so a wrap // step has a real cell to slide into before `settle` snaps back to the matching slide. const slides = Children.toArray(children); const first = slides[0]; const last = slides[slides.length - 1]; const trackChildren = continuous && slides.length > 1 ? [ isValidElement(last) ? cloneElement(last, { key: 'clone-last' }) : last, ...slides, isValidElement(first) ? cloneElement(first, { key: 'clone-first' }) : first, ] : slides; // Leading clone occupies the first cell in continuous mode, so shift the translate by one. const translate = continuous ? -(offset + 1) * 100 : -offset * 100; const handleTransitionEnd = useCallback( (event: TransitionEvent) => { if (event.target === event.currentTarget && event.propertyName === 'transform') { settle(); } }, [settle], ); return (
{transition === 'slide' ? ( // Lay slides side-by-side in a flex track and translate by the active index. Each slide // sizes itself to the viewport via `shrink-0 basis-full` (see `Carousel.Slide`). // `motion-reduce` respects the user's reduced-motion preference; the snap after a wrap also // disables the transition (`animate === false`).
{trackChildren}
) : ( children )}
); }; CarouselViewport.displayName = 'Carousel.Viewport'; // // Slide // export type CarouselSlideProps = ThemedClassName<{ index: number; /** Media source URL — rendered via the embedded {@link MediaPlayer}. */ src: string; /** Override media auto-detection (`'video' | 'audio'`). */ kind?: MediaKind; /** Accessible label / `` fallback. */ alt?: string; controls?: boolean; autoPlay?: boolean; loop?: boolean; muted?: boolean; crossOrigin?: 'anonymous' | 'use-credentials' | ''; }>; const CarouselSlide = ({ index, classNames, src, kind, alt, controls, autoPlay, loop, muted, crossOrigin, }: CarouselSlideProps) => { const { index: active, transition } = useCarousel(); // In `slide` mode every slide stays mounted as a fixed-width track cell so the track can animate; // in `none` mode only the active slide mounts, overlaid via absolute positioning. if (transition !== 'slide' && active !== index) { return null; } return (
); }; CarouselSlide.displayName = 'Carousel.Slide'; // // Previous / Next // export type CarouselButtonProps = ThemedClassName<{}>; const CarouselPrevious = ({ classNames }: CarouselButtonProps) => { const { t } = useTranslation(translationKey); const { count, prev } = useCarousel(); if (count <= 1) { return
; } return ( ); }; CarouselPrevious.displayName = 'Carousel.Previous'; const CarouselNext = ({ classNames }: CarouselButtonProps) => { const { t } = useTranslation(translationKey); const { count, next } = useCarousel(); if (count <= 1) { return
; } return ( ); }; CarouselNext.displayName = 'Carousel.Next'; // // Indicators // export type CarouselIndicatorsProps = ThemedClassName<{}>; /** Tab-strip of slide indicators. Sits in the centre column so it matches the viewport's width. */ const CarouselIndicators = ({ classNames }: CarouselIndicatorsProps) => { const { t } = useTranslation(translationKey); const { count, index, setIndex } = useCarousel(); const arrowNavigationAttrs = useArrowNavigationGroup({ axis: 'horizontal', memorizeCurrent: true }); if (count <= 1) { return null; } return (
{Array.from({ length: count }).map((_, i) => ( setIndex(i)} onFocus={() => setIndex(i)} /> ))}
); }; CarouselIndicators.displayName = 'Carousel.Indicators'; // // Caption // export type CarouselCaptionProps = ThemedClassName<{ /** Render prop receiving the active slide index. */ children: (index: number) => ReactNode; }>; /** Caption sized to the viewport's column. */ const CarouselCaption = ({ children, classNames }: CarouselCaptionProps) => { const { index } = useCarousel(); const content = children(index); if (content == null || content === false || content === '') { return null; } return ( // TODO(burdon): Move to ui-theme.

{content}

); }; CarouselCaption.displayName = 'Carousel.Caption'; // // Carousel // export const Carousel = { Root: CarouselRoot, Content: CarouselContent, Viewport: CarouselViewport, Slide: CarouselSlide, Previous: CarouselPrevious, Next: CarouselNext, Indicators: CarouselIndicators, Caption: CarouselCaption, };