diff --git a/app_crates/registry/src/demos/demo_stepper.rs b/app_crates/registry/src/demos/demo_stepper.rs new file mode 100644 index 0000000..dced263 --- /dev/null +++ b/app_crates/registry/src/demos/demo_stepper.rs @@ -0,0 +1,42 @@ +use leptos::prelude::*; + +use crate::ui::stepper::{ + Stepper, StepperDescription, StepperIndicator, StepperItem, StepperSeparator, StepperTitle, StepperTrigger, +}; + +#[component] +pub fn DemoStepper() -> impl IntoView { + view! { + + + + +
+ "Account" + "Create your account" +
+
+ +
+ + + +
+ "Profile" + "Complete your profile" +
+
+ +
+ + + +
+ "Confirmation" + "Review and confirm" +
+
+
+
+ } +} diff --git a/app_crates/registry/src/demos/demo_stepper_controlled.rs b/app_crates/registry/src/demos/demo_stepper_controlled.rs new file mode 100644 index 0000000..ffa94cf --- /dev/null +++ b/app_crates/registry/src/demos/demo_stepper_controlled.rs @@ -0,0 +1,61 @@ +use leptos::prelude::*; + +use crate::hooks::use_stepper::StepperContext; +use crate::ui::button::Button; +use crate::ui::stepper::{ + Stepper, StepperDescription, StepperIndicator, StepperItem, StepperSeparator, StepperTitle, StepperTrigger, +}; + +#[component] +fn StepperControls() -> impl IntoView { + let ctx = expect_context::(); + + view! { +
+ + +
+ } +} + +#[component] +pub fn DemoStepperControlled() -> impl IntoView { + view! { + + + + +
+ "Account" + "Create your account" +
+
+ +
+ + + +
+ "Profile" + "Complete your profile" +
+
+ +
+ + + +
+ "Confirmation" + "Review and confirm" +
+
+
+ +
+ } +} diff --git a/app_crates/registry/src/demos/demo_stepper_vertical.rs b/app_crates/registry/src/demos/demo_stepper_vertical.rs new file mode 100644 index 0000000..21f59d7 --- /dev/null +++ b/app_crates/registry/src/demos/demo_stepper_vertical.rs @@ -0,0 +1,43 @@ +use leptos::prelude::*; + +use crate::ui::stepper::{ + Stepper, StepperDescription, StepperIndicator, StepperItem, StepperOrientation, StepperSeparator, StepperTitle, + StepperTrigger, +}; + +#[component] +pub fn DemoStepperVertical() -> impl IntoView { + view! { + + + + +
+ "Account" + "Create your account" +
+
+ +
+ + + +
+ "Profile" + "Complete your profile" +
+
+ +
+ + + +
+ "Confirmation" + "Review and confirm" +
+
+
+
+ } +} diff --git a/app_crates/registry/src/demos/mod.rs b/app_crates/registry/src/demos/mod.rs index 86390b2..54d4fc2 100644 --- a/app_crates/registry/src/demos/mod.rs +++ b/app_crates/registry/src/demos/mod.rs @@ -286,6 +286,9 @@ pub mod demo_spinner_button; pub mod demo_spinner_rtl; pub mod demo_status; pub mod demo_status_variants; +pub mod demo_stepper; +pub mod demo_stepper_controlled; +pub mod demo_stepper_vertical; pub mod demo_switch; pub mod demo_switch_choice_card; pub mod demo_switch_rtl; diff --git a/app_crates/registry/src/hooks/mod.rs b/app_crates/registry/src/hooks/mod.rs index 78c2673..3e3e040 100644 --- a/app_crates/registry/src/hooks/mod.rs +++ b/app_crates/registry/src/hooks/mod.rs @@ -25,5 +25,6 @@ pub mod use_pagination; pub mod use_press_hold; pub mod use_random; pub mod use_scroll_lock; +pub mod use_stepper; pub mod use_theme_mode; pub mod use_virtual_scroll; diff --git a/app_crates/registry/src/hooks/use_stepper.rs b/app_crates/registry/src/hooks/use_stepper.rs new file mode 100644 index 0000000..c0f23b9 --- /dev/null +++ b/app_crates/registry/src/hooks/use_stepper.rs @@ -0,0 +1,79 @@ +use leptos::prelude::*; + +/// Visual/interactive state of a single step, relative to the current index. +/// +/// `Completed`/`Active`/`Pending` are derived automatically from index +/// comparison. `Disabled` is not produced by this hook — it's applied by +/// the caller (e.g. `StepperItem`) on top of the computed state, since it +/// depends on external conditions the hook has no visibility into. +#[derive(Clone, Copy, PartialEq, Eq, strum::Display)] +pub enum StepState { + Completed, + Active, + Pending, + Disabled, +} + +/// Shared reactive state for a `Stepper` instance, provided via +/// `provide_context` and consumed by `StepperItem`/`StepperTrigger`. +#[derive(Clone)] +pub struct StepperContext { + pub current_index: RwSignal, + pub total_steps: usize, + pub can_go_prev: Signal, + pub can_go_next: Signal, + pub go_next: Callback<(), ()>, + pub go_prev: Callback<(), ()>, + pub go_to: Callback, + pub step_state: Callback, +} + +/// Builds the controlled navigation state for a stepper with `total_steps` +/// steps, starting at `default_index`. +/// +/// All navigation methods (`go_next`, `go_prev`, `go_to`) clamp to +/// `[0, total_steps)`, so `current_index` can never be set out of range — +/// callers indexing a step list with it don't need to re-validate. +pub fn use_stepper(total_steps: usize, default_index: usize) -> StepperContext { + // Clamp in case `default_index` is out of range (e.g. caller passes total_steps itself). + let current_index = RwSignal::new(default_index.min(total_steps.saturating_sub(1))); + + // Reactive rather than one-shot, so nav buttons can bind `disabled` directly + // and re-evaluate whenever `current_index` changes. + let can_go_prev = Signal::derive(move || current_index.get() > 0); + let can_go_next = Signal::derive(move || current_index.get() + 1 < total_steps); + + let go_prev = Callback::new(move |_| { + if current_index.get() > 0 { + current_index.update(|i| *i -= 1); + } + }); + + let go_next = Callback::new(move |_| { + if current_index.get() + 1 < total_steps { + current_index.update(|i| *i += 1); + } + }); + + // Backs clickable step triggers — jumps straight to an arbitrary index + // rather than stepping by one, so out-of-range values need their own guard. + let go_to = Callback::new(move |index: usize| { + if index < total_steps { + current_index.set(index); + } + }); + + // Maps the issue's three-way rule (step < current -> completed, == -> active, + // > -> pending) onto Ordering so it reads as one exhaustive match. + let step_state = Callback::new(move |step: usize| { + let current = current_index.get(); + + match step.cmp(¤t) { + std::cmp::Ordering::Less => StepState::Completed, + std::cmp::Ordering::Equal => StepState::Active, + std::cmp::Ordering::Greater => StepState::Pending, + } + }); + + StepperContext { current_index, total_steps, can_go_prev, can_go_next, go_next, go_prev, go_to, step_state } +} diff --git a/app_crates/registry/src/ui/mod.rs b/app_crates/registry/src/ui/mod.rs index 6588ec4..165f0fa 100644 --- a/app_crates/registry/src/ui/mod.rs +++ b/app_crates/registry/src/ui/mod.rs @@ -79,6 +79,7 @@ pub mod slider; pub mod sonner; pub mod spinner; pub mod status; +pub mod stepper; pub mod switch; pub mod table; pub mod tabs; diff --git a/app_crates/registry/src/ui/stepper.rs b/app_crates/registry/src/ui/stepper.rs new file mode 100644 index 0000000..288567f --- /dev/null +++ b/app_crates/registry/src/ui/stepper.rs @@ -0,0 +1,214 @@ +use icons::Check; +use leptos::prelude::*; +use leptos_ui::{clx, variants, void}; +use tw_merge::tw_merge; + +use crate::hooks::use_stepper::{StepState, StepperContext, use_stepper}; + +/* ========================================================== */ +/* Enums */ +/* ========================================================== */ + +/// Layout direction for a `Stepper` — controls both the root flex direction +/// and which `StepperSeparator` styling (inline bar vs. absolute vertical +/// line) applies, via the `data-orientation` attribute on the root element. +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +pub enum StepperOrientation { + #[default] + Horizontal, + Vertical, +} + +#[derive(Clone, Copy)] +struct StepperItemCtx { + step: usize, + state: Memo, +} + +/* ========================================================== */ +/* Tailwind Variants */ +/* ========================================================== */ + +variants! { + StepperIndicator { + base: "flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-medium transition-colors", + variants: { + variant: { + Pending: "border-border bg-background text-muted-foreground", + Active: "border-primary bg-primary text-primary-foreground", + Completed: "border-primary bg-primary text-primary-foreground", + Disabled: "border-border bg-muted text-muted-foreground/50", + } + } + } +} + +impl From for StepperIndicatorVariant { + fn from(state: StepState) -> Self { + match state { + StepState::Completed => StepperIndicatorVariant::Completed, + StepState::Active => StepperIndicatorVariant::Active, + StepState::Pending => StepperIndicatorVariant::Pending, + StepState::Disabled => StepperIndicatorVariant::Disabled, + } + } +} + +/* ========================================================== */ +/* Structural components (clx! / void!) */ +/* ========================================================== */ + +mod components { + use super::*; + + clx! { + StepperTitle, div, + "text-sm font-medium text-foreground transition-colors", + "group-data-[state=Pending]/stepper-item:text-muted-foreground", + "group-data-[state=Disabled]/stepper-item:text-muted-foreground/50" + } + + clx! { + StepperDescription, div, + "text-sm text-muted-foreground transition-colors", + "group-data-[state=Disabled]/stepper-item:text-muted-foreground/50" + } + + void! { + StepperSeparator, div, + "shrink-0 bg-border transition-colors", + "group-data-[orientation=Horizontal]/stepper:self-center group-data-[orientation=Horizontal]/stepper:h-0.5 group-data-[orientation=Horizontal]/stepper:w-full", + "group-data-[orientation=Vertical]/stepper:absolute group-data-[orientation=Vertical]/stepper:top-8 group-data-[orientation=Vertical]/stepper:left-4 group-data-[orientation=Vertical]/stepper:h-full group-data-[orientation=Vertical]/stepper:w-0.5", + "group-data-[state=Completed]/stepper-item:bg-primary" + } +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +/// Root provider — builds the shared `StepperContext` and exposes it to +/// every descendant `StepperItem`/`StepperTrigger` via `provide_context`. +#[component] +pub fn Stepper( + total_steps: usize, + #[prop(default = 0)] default_step: usize, + #[prop(default = StepperOrientation::Horizontal)] orientation: StepperOrientation, + #[prop(into, optional)] class: String, + children: Children, +) -> impl IntoView { + let ctx = use_stepper(total_steps, default_step); + provide_context(ctx); + + let orientation_str = orientation.to_string(); + let class = tw_merge!( + "group/stepper flex w-full", + if orientation == StepperOrientation::Horizontal { "flex-row items-start" } else { "flex-col" }, + class + ); + + view! { +
+ {children()} +
+ } +} + +/// One step's wrapper — reads the shared context to derive this step's +/// `StepState`, folding in the locally-supplied `disabled` prop. +#[component] +pub fn StepperItem( + step: usize, + #[prop(default = false)] disabled: bool, + #[prop(into, optional)] class: String, + children: Children, +) -> impl IntoView { + let ctx = expect_context::(); + + let state = Memo::new(move |_| if disabled { StepState::Disabled } else { ctx.step_state.run(step) }); + provide_context(StepperItemCtx { step, state }); + + let class = tw_merge!( + "group/stepper-item relative flex flex-1 items-start gap-2", + "group-data-[orientation=Vertical]/stepper:flex-col", + class + ); + + view! { +
+ {children()} +
+ } +} + +/// Clickable native ` + } +} + +/// Step's visual dot — number by default, checkmark once completed, or fully +/// custom content via `children`. Colors come from `StepperIndicatorVariant`, +/// derived from this step's `StepState`. +#[component] +pub fn StepperIndicator( + #[prop(into, optional)] class: String, + #[prop(optional)] children: Option, +) -> impl IntoView { + let item_ctx = expect_context::(); + + let indicator_class = move || { + let variant: StepperIndicatorVariant = item_ctx.state.get().into(); + StepperIndicatorClass { variant }.with_class(class.clone()) + }; + + view! { + + } +} diff --git a/public/docs/changelog.md b/public/docs/changelog.md index d5eb62b..2dc79b4 100644 --- a/public/docs/changelog.md +++ b/public/docs/changelog.md @@ -13,6 +13,7 @@ image_dark = "/images/thumbnails/_placeholder-dark.webp" ### New Components +- **[Stepper](/docs/components/stepper)**: Multi-step workflow indicator with horizontal and vertical orientations, `Completed`/`Active`/`Pending`/`Disabled` step states, clickable native-button triggers with `aria-current="step"`, and custom indicator content support. Includes 3 demos. - **[Attachment](/docs/components/attachment)**: File and image attachment card with media slot, title, description, upload states (`Idle`, `Uploading`, `Processing`, `Error`, `Done`), three sizes, and an `AttachmentTrigger` overlay for link/button activation. Includes 6 demos. - **[Bubble](/docs/components/bubble)**: Chat message bubble with 7 variants, `BubbleReactions` overlay, and `BubbleContent` that renders as ``, `