import type { HTMLAttributes, ReactNode } from 'react'
import { IoAlert, IoCheckmark } from 'react-icons/io5'
import { cx } from '../../utils/cx'
import { usePressFeedback } from '../../hooks/usePressFeedback'
import { useSlidingThumb } from '../../motion/useSlidingThumb'
import type { MaySize } from '../../types'
/** The marker disc lifts a little under the press. Subtle — it wears a halo. */
const PRESS_SCALE = 1.12
/**
* `error` is not derived from `current` — it is the only status a caller has
* to declare, because nothing about an index says a step failed.
*/
export type StepStatus = 'complete' | 'current' | 'upcoming' | 'error'
export type StepsOrientation = 'horizontal' | 'vertical'
/** xs is absent: a marker that small stops being a touch target. */
export type StepsSize = Exclude
export interface StepItem {
title: ReactNode
description?: ReactNode
/** Overrides the status derived from `current` — an upload that failed. */
status?: StepStatus
/** Replaces the number inside the marker. */
icon?: ReactNode
}
export interface StepsProps extends Omit, 'onChange'> {
items: StepItem[]
/** Zero-based index of the step in progress. */
current: number
/** @default 'horizontal' */
orientation?: StepsOrientation
/**
* Let a finished step be pressed to go back to it. Steps ahead of the
* current one stay disabled — a stepper is not a tab strip.
*/
clickable?: boolean
onStepChange?: (index: number) => void
/** @default 'md' */
size?: StepsSize
/** Accessible name for the sequence. @default 'Progress' */
'aria-label'?: string
}
/** Spoken, never seen: the marker's colour carries this for everyone else. */
const STATUS_LABEL: Record = {
complete: 'Completed',
current: 'Current step',
upcoming: 'Not started',
error: 'Failed',
}
interface StepProps {
item: StepItem
index: number
status: StepStatus
last: boolean
clickable: boolean
markerRef: (node: HTMLSpanElement | null) => void
onSelect?: (index: number) => void
}
function Step({ item, index, status, last, clickable, markerRef, onSelect }: StepProps) {
// Going forward is the stepper's job, not the user's: only ground already
// covered can be pressed.
const interactive = clickable && status !== 'upcoming'
const { pressProps } = usePressFeedback(!interactive)
const marker = (
{item.icon ??
(status === 'complete' ? (
) : status === 'error' ? (
) : (
index + 1
))}
)
const body = (
<>
{marker}
{item.title}
{item.description && {item.description}}
{STATUS_LABEL[status]}
>
)
return (
{clickable ? (
) : (
{body}
)}
{/* Furniture between two markers; the status word above already says
* everything this line is drawing. */}
{!last && }
)
}
/**
* Numbered progress through a sequence — a checkout, a device setup, an
* onboarding flow.
*
* The connector between two markers is a track with a tinted fill that grows
* along it as each step completes, so advancing reads as the line *filling in*
* rather than a colour changing between frames. Nothing here is drawn with a
* border: the track is a fill, the markers are fills, and the current one
* carries a halo rather than a ring.
*/
export function Steps({
items = [],
current,
orientation = 'horizontal',
clickable = false,
onStepChange,
size = 'md',
className,
'aria-label': ariaLabel = 'Progress',
...rest
}: StepsProps) {
const statusAt = (index: number): StepStatus =>
items[index]?.status ??
(index < current ? 'complete' : index === current ? 'current' : 'upcoming')
/*
* A filled disc that SLIDES to the current step instead of one marker's fill
* switching off as another's switches on. Press-only: tapping a finished step
* is what moves the selection (the step's own button owns that click), and
* the thumb follows — a stepper is walked, not scrubbed, and a live drag would
* have to pass the solid disc through the intermediate markers' own fills.
* Inert unless `clickable`, so a read-only progress display is untouched.
*/
const { trackRef, thumbRef, registerItem, onPointerDown } = useSlidingThumb<
HTMLOListElement,
HTMLSpanElement
>({
axis: orientation === 'vertical' ? 'block' : 'inline',
itemCount: items.length,
selectedIndex: current,
roundEnds: true,
pressScale: PRESS_SCALE,
enabled: clickable,
})
return (
{clickable && }
{items.map((item, index) => (
))}
)
}