// ~/src/siteplane/components/stepper/stepper.tsx import { cva, type VariantProps } from "class-variance-authority"; import { Check, Lock } from "lucide-react"; import * as React from "react"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { KitButton } from "../button/button-internal"; import { MotionReveal, MotionSwap } from "../motion"; export function Stepper({ className, ...props }: React.ComponentProps<"ol">): React.ReactElement { return (
    ); } // A step that changes status eases its colours instead of switching them. The // marker's edge, ring and transitions are design-rework.css's, so the reduced // motion modes switch them off. const stepIndicatorVariants = cva( "relative z-10 flex shrink-0 items-center justify-center rounded-full font-(--sp-rw-font-weight-strong) after:pointer-events-none after:absolute after:-inset-1 after:origin-center after:scale-75 after:rounded-full after:opacity-0 after:content-['']", { defaultVariants: { status: "open" }, variants: { status: { blocked: "border-dashed", current: "", done: "", open: "", }, }, }, ); type StepperItemStatus = VariantProps["status"]; // Done and blocked markers are icons only, so the title carries their status // as visually hidden text. const defaultStatusLabels: Partial< Record, string> > = { blocked: "blocked", done: "completed", }; // A step is the marker and its text, then the body, with the connector to the // next step running down under the marker, at the step's inline start: on the // right in a right-to-left page. The body opens and closes inside the step, so // a step keeps its marker, title and connector when its content changes. const stepItemClassName = "relative pb-5 last:pb-0 before:absolute before:top-7 before:bottom-0 before:start-3 before:-translate-x-1/2 rtl:before:translate-x-1/2 last:before:hidden"; export interface StepperItemProps extends Omit, "title">, VariantProps { title: React.ReactNode; /** * Supporting text under the title. Text that changes cross-fades; give a * description element a `key` to cross-fade between elements, or keep the * same `key` to update it in place. */ description?: React.ReactNode; index?: number; statusLabel?: string; /** * The content of the step, shown under its text. It expands when children * appear and collapses when they go away, so keep the step mounted between * renders and pass children only while the step shows its content. */ children?: React.ReactNode; } export function StepperItem({ className, status = "open", title, description, index, statusLabel, children, ...props }: StepperItemProps): React.ReactElement { return (
  1. {children}
  2. ); } export interface StepperButtonItemProps extends Omit, "title">, VariantProps { title: React.ReactNode; /** See `StepperItem`: text that changes cross-fades. */ description?: React.ReactNode; index?: number; /** * Marks the step that is open (`aria-current="step"`), independent of its * `status`. */ active?: boolean; /** @deprecated Use `active`. */ current?: boolean; statusLabel?: string; /** * The content of the step, shown under its text and outside the button, so * it can hold fields and links. It expands when children appear and * collapses when they go away: one item can be the clickable header of a * step and open as the current step, without being replaced. */ children?: React.ReactNode; } export function StepperButtonItem({ className, status = "open", title, description, index, active: activeProp, current, statusLabel, children, ...props }: StepperButtonItemProps): React.ReactElement { const active = activeProp ?? current ?? false; // The title and its status name the button; the marker number and the // description would otherwise run into one long name. const titleId = React.useId(); const descriptionId = React.useId(); return (
  3. {children}
  4. ); } /** * The marker's content: a number, or a check or lock for a done or blocked * step. A new content cross-fades in the same place. */ function StepperIndicator({ index, status, }: { index?: number | undefined; status: StepperItemStatus; }) { const content = status === "done" ? ( ) : status === "blocked" ? ( ) : index != null ? ( index ) : null; const contentKey = status === "done" || status === "blocked" ? status : `number:${index}`; return ( {content} ); } /** * What identifies a description for the swap: text by its value, an element by * its `key` or else its type. */ function descriptionKey(description: React.ReactNode): React.Key { if (typeof description === "string" || typeof description === "number") return `text:${description}`; if (React.isValidElement(description)) return ( description.key ?? `type:${typeof description.type === "string" ? description.type : "component"}` ); return "none"; } function StepperContent({ description, descriptionId, status, statusLabel, title, titleId, }: { description?: React.ReactNode; descriptionId?: string | undefined; status: StepperItemStatus; statusLabel?: string | undefined; title: React.ReactNode; titleId?: string | undefined; }) { const hiddenStatus = statusLabel ?? (status ? defaultStatusLabels[status] : undefined); return (
    {title} {hiddenStatus ? ( {` (${hiddenStatus})`} ) : null}
    {description}
    ); } /** * The step's content under its text, in line with the title. The 12 px above * it are part of the body, so they open and close with it. */ function StepperBody({ children }: { children?: React.ReactNode }) { return ( 0}>
    {children}
    ); } // ~/src/siteplane/components/stepper/index.ts export * from "./stepper";