// ~/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 (
{children}
);
}
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 (
{children}
);
}
/**
* 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";