'use client'; import React, { forwardRef, useCallback, useMemo, useRef } from 'react'; import { Surface, cn, focusRing, surfaceClasses, toneMap, useEffectiveSurface, CheckIcon, CloseIcon, } from '../common'; type Size = 'sm' | 'md' | 'lg'; type StepState = 'pending' | 'active' | 'completed' | 'error'; interface StepperCtx { active: number; orientation: 'horizontal' | 'vertical'; allowNextStepsSelect: boolean; onStepClick?: (idx: number) => void; size: Size; surface: Surface; total: number; registerStep: (idx: number, el: HTMLDivElement | null) => void; focusByOffset: (currentIdx: number, offset: number) => void; focusEdge: (edge: 'first' | 'last') => void; } const StepperContext = React.createContext(null); const StepIndexContext = React.createContext(-1); function useStepperCtx(): StepperCtx { const ctx = React.useContext(StepperContext); if (!ctx) { throw new Error('PixelStepper.Step must be used inside '); } return ctx; } const indicatorSize: Record = { sm: 'h-6 w-6 text-[10px]', md: 'h-8 w-8 text-xs', lg: 'h-10 w-10 text-sm', }; const labelSize: Record = { sm: 'text-[11px]', md: 'text-xs', lg: 'text-sm', }; const descSize: Record = { sm: 'text-[10px]', md: 'text-[11px]', lg: 'text-xs', }; function Spinner({ className }: { className?: string }) { return ( ); } /* ────────────────────────────────────────────────────────────────────────── PixelStepper.Step ────────────────────────────────────────────────────────────────────────── */ export interface PixelStepperStepProps extends Omit, 'children'> { label: string; description?: string; icon?: React.ReactNode; loading?: boolean; completed?: boolean; error?: boolean; children?: React.ReactNode; } export const PixelStepperStep = forwardRef( function PixelStepperStep( { label, description, icon, loading = false, completed = false, error = false, children: _children, className, onClick, ...rest }, ref, ) { const ctx = useStepperCtx(); const index = React.useContext(StepIndexContext); const s = surfaceClasses(ctx.surface); const isActive = index === ctx.active; const state: StepState = error ? 'error' : completed ? 'completed' : isActive ? 'active' : 'pending'; const clickable = !!ctx.onStepClick && (ctx.allowNextStepsSelect || index <= ctx.active); const tone = state === 'error' ? 'red' : state === 'completed' ? 'green' : state === 'active' ? 'cyan' : 'neutral'; const t = toneMap[tone]; const handleClick = (e: React.MouseEvent) => { onClick?.(e); if (e.defaultPrevented) return; if (clickable) ctx.onStepClick?.(index); }; const handleKeyDown = (e: React.KeyboardEvent) => { const isVertical = ctx.orientation === 'vertical'; const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight'; const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft'; switch (e.key) { case nextKey: e.preventDefault(); ctx.focusByOffset(index, 1); return; case prevKey: e.preventDefault(); ctx.focusByOffset(index, -1); return; case 'Home': e.preventDefault(); ctx.focusEdge('first'); return; case 'End': e.preventDefault(); ctx.focusEdge('last'); return; case 'Enter': case ' ': if (!clickable) return; e.preventDefault(); ctx.onStepClick?.(index); return; default: return; } }; const indicatorContent: React.ReactNode = (() => { if (loading) { return ; } if (state === 'completed') { return ( ); } if (state === 'error') { return ( ); } if (icon) { return ( {icon} ); } return {index + 1}; })(); const ariaLabel = `Step ${index + 1} of ${ctx.total}: ${label}${ state === 'completed' ? ' (completed)' : state === 'error' ? ' (error)' : state === 'active' ? ' (current)' : '' }`; const isVertical = ctx.orientation === 'vertical'; const setRefs = useCallback( (node: HTMLDivElement | null) => { ctx.registerStep(index, node); if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }, [ctx, index, ref], ); return (
{indicatorContent}
{label} {description ? ( {description} ) : null}
); }, ); PixelStepperStep.displayName = 'PixelStepper.Step'; /* ────────────────────────────────────────────────────────────────────────── PixelStepper (root) ────────────────────────────────────────────────────────────────────────── */ export interface PixelStepperProps extends Omit, 'children'> { active: number; onStepClick?: (idx: number) => void; orientation?: 'horizontal' | 'vertical'; allowNextStepsSelect?: boolean; size?: Size; surface?: Surface; /** Accessible name for the steps landmark. Defaults to "Progress steps". */ ariaLabel?: string; children: React.ReactNode; } const PixelStepperRoot = forwardRef(function PixelStepperRoot( { active, onStepClick, orientation = 'horizontal', allowNextStepsSelect = false, size = 'md', surface: surfaceProp, ariaLabel = 'Progress steps', className, children, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const childArray = useMemo( () => React.Children.toArray(children).filter(React.isValidElement), [children], ); const total = childArray.length; const stepRefs = useRef>(new Map()); const registerStep = useCallback((idx: number, el: HTMLDivElement | null) => { if (el) stepRefs.current.set(idx, el); else stepRefs.current.delete(idx); }, []); const isClickableIdx = useCallback( (idx: number) => !!onStepClick && (allowNextStepsSelect || idx <= active), [onStepClick, allowNextStepsSelect, active], ); const focusByOffset = useCallback( (currentIdx: number, offset: number) => { if (total === 0) return; const direction = offset > 0 ? 1 : -1; // Find next clickable in the requested direction, skipping non-focusables. let next = currentIdx + direction; while (next >= 0 && next < total) { if (isClickableIdx(next)) { stepRefs.current.get(next)?.focus(); return; } next += direction; } }, [total, isClickableIdx], ); const focusEdge = useCallback( (edge: 'first' | 'last') => { if (total === 0) return; const range = edge === 'first' ? Array.from({ length: total }, (_, i) => i) : Array.from({ length: total }, (_, i) => total - 1 - i); for (const idx of range) { if (isClickableIdx(idx)) { stepRefs.current.get(idx)?.focus(); return; } } }, [total, isClickableIdx], ); const ctx: StepperCtx = { active, orientation, allowNextStepsSelect, onStepClick, size, surface, total, registerStep, focusByOffset, focusEdge, }; const isVertical = orientation === 'vertical'; return (
{childArray.map((child, i) => { const isLast = i === total - 1; const nextState: StepState = i < active ? 'completed' : i === active ? 'active' : 'pending'; const connectorTone = nextState === 'completed' ? 'green' : 'neutral'; const ct = toneMap[connectorTone]; if (isVertical) { return (
{child} {!isLast ? ( ) : null}
); } return ( {child} {!isLast ? (
) : null}
); })}
); }); PixelStepperRoot.displayName = 'PixelStepper'; /* ── Namespace export — dot-notation compound component ──────────────── */ type PixelStepperNamespace = typeof PixelStepperRoot & { Step: typeof PixelStepperStep; }; const PixelStepperBase = PixelStepperRoot as PixelStepperNamespace; PixelStepperBase.Step = PixelStepperStep; export const PixelStepper = PixelStepperBase;