Onboarding · Flows and highlights: wizard, hotspot, highlight, setup checklist

OnboardingWizard runs a first run on navigation::Wizard without its
numbered steps: "Step 2 of 4" over a bar in as many parts, the step's
title, line and content, Back and Next once ready, Finish last, and
Skip for now. Hotspot pulses on a dot that opens a tip on a press or
Enter, with Got it. FeatureHighlight names something new under a New
badge, with Try it and Not now, and rises in. SetupChecklist counts
first things to do in words and a bar, offers Start on each left, eases
a check in as one gets done, and offers Hide once all are.

navigation::Wizard now keys its buttons, steps and motion by its own id
and takes on_step and on_finish at render, failing loud without them.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 14:20:45 -04:00
1 parent c68e3229bb
commit eea99b8e41
16 files changed
+1288 -27

No files matched your search

+40 -20
View File
@@ -62,7 +62,8 @@ impl Steps {
impl RenderOnce for Steps {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let turn = motion::changes((self.id.clone(), "current"), self.current, window, cx);
let id = self.id.clone();
let turn = motion::changes((id.clone(), "current"), self.current, window, cx);
let (current, vertical, count) = (self.current, self.vertical, self.steps.len());
let duration = motion::duration(motion::SLOW, cx);
let theme = cx.theme();
@@ -79,7 +80,7 @@ impl RenderOnce for Steps {
});
let fill = if filled && ix + 1 == current && turn > 0 {
fill.with_animation(
("step-fill", turn),
(id.clone(), format!("fill-{turn}")),
Animation::new(duration).with_easing(motion::ease_out_cubic),
move |fill, t| {
if vertical {
@@ -155,7 +156,7 @@ impl RenderOnce for Steps {
});
let select = self.on_select.clone().filter(|_| done);
let head = div()
.id(("step", ix))
.id((id.clone(), format!("step-{ix}")))
.flex()
.items_center()
.gap_2()
@@ -215,6 +216,7 @@ pub struct Wizard {
current: usize,
content: Option<AnyElement>,
ready: bool,
headless: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
}
@@ -225,17 +227,30 @@ impl Wizard {
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
let steps: Vec<Choice> = steps.into_iter().collect();
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps: steps.into_iter().collect(),
steps,
current,
content: None,
ready: true,
headless: false,
on_step: None,
on_finish: None,
}
}
/// Leaves out the numbered steps; the owner shows where the flow stands.
pub(crate) fn headless(mut self) -> Self {
self.headless = true;
self
}
/// The current step's content.
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
@@ -264,28 +279,31 @@ impl RenderOnce for Wizard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
let turn = motion::changes((self.id.clone(), "content"), current, window, cx);
let id = self.id.clone();
let on_step = self
.on_step
.unwrap_or_else(|| panic!("wizard {id:?} has no on_step"));
let on_finish = self
.on_finish
.unwrap_or_else(|| panic!("wizard {id:?} has no on_finish"));
let step: OnStep = {
let (id, on_step) = (self.id.clone(), self.on_step);
let id = id.clone();
Rc::new(move |to, window, cx| {
log::info!("wizard {id:?}: step {to}");
if let Some(on_step) = &on_step {
on_step(to, window, cx);
}
on_step(to, window, cx);
})
};
let (back, next, jump) = (step.clone(), step.clone(), step);
let finish = {
let (id, on_finish) = (self.id.clone(), self.on_finish);
let id = id.clone();
move |window: &mut Window, cx: &mut App| {
log::info!("wizard {id:?}: finished");
if let Some(on_finish) = &on_finish {
on_finish(window, cx);
}
on_finish(window, cx);
}
};
let content = self.content.map(|content| {
div().child(content).with_animation(
("wizard-step", turn),
(id.clone(), format!("step-{turn}")),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |content, t| {
@@ -298,12 +316,12 @@ impl RenderOnce for Wizard {
)
});
let advance = if current == last {
Button::new("wizard-finish", "Finish")
Button::new((id.clone(), "finish"), "Finish")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| finish(window, cx))
} else {
Button::new("wizard-next", "Next")
Button::new((id.clone(), "next"), "Next")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| next(current + 1, window, cx))
@@ -314,10 +332,12 @@ impl RenderOnce for Wizard {
.flex_col()
.gap_6()
.w_full()
.child(
Steps::new((self.id, "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
)
.when(!self.headless, |wizard| {
wizard.child(
Steps::new((id.clone(), "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
)
})
.children(content)
.child(
div()
@@ -325,7 +345,7 @@ impl RenderOnce for Wizard {
.items_center()
.justify_between()
.child(
Button::new("wizard-back", "Back")
Button::new((id.clone(), "back"), "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),