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:
1 parent
c68e3229bb
commit
eea99b8e41
16 files changed
+1288
-27
No files matched your search
@@ -26,6 +26,7 @@ mod menus;
|
||||
mod messaging;
|
||||
mod motion;
|
||||
mod navigation;
|
||||
mod onboarding;
|
||||
mod overlays;
|
||||
mod primitives;
|
||||
mod project;
|
||||
@@ -87,6 +88,7 @@ pub const ALL: &[Page] = &[
|
||||
dashboard::PAGE,
|
||||
settings::PAGE,
|
||||
account::PAGE,
|
||||
onboarding::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
data_display::{Badge, Tone},
|
||||
forms::{FormField, Input, TextInput},
|
||||
lists::{List, ListItem},
|
||||
onboarding::{
|
||||
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
|
||||
},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
/// The wizard demo: its step, and whether it was finished.
|
||||
#[derive(Default)]
|
||||
struct Welcome {
|
||||
step: usize,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
hint: &'static str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(hint)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wizard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-welcome", Welcome::default, window, cx);
|
||||
let name = field("onboarding-name", "Acme", window, cx);
|
||||
let invite = field("onboarding-invite", "name@example.com", window, cx);
|
||||
let (step, finished) = (state.read(cx).step, state.read(cx).finished);
|
||||
let step_of = |key: &str, title: &str, body: &str| OnboardingStep {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
body: body.to_string().into(),
|
||||
};
|
||||
let steps = [
|
||||
step_of(
|
||||
"name",
|
||||
"Name your workspace",
|
||||
"It shows in the sidebar and in every invitation.",
|
||||
),
|
||||
step_of(
|
||||
"invite",
|
||||
"Bring your team",
|
||||
"Add an address now, or later from Settings.",
|
||||
),
|
||||
step_of(
|
||||
"ready",
|
||||
"You're set",
|
||||
"Your workspace is ready. Press Finish to open it.",
|
||||
),
|
||||
];
|
||||
let ready = step != 0 || !name.read(cx).text().trim().is_empty();
|
||||
let content = match step {
|
||||
0 => FormField::new("onboarding-name-field", "Workspace name")
|
||||
.child(Input::new(&name))
|
||||
.into_any_element(),
|
||||
1 => FormField::new("onboarding-invite-field", "Email")
|
||||
.child(Input::new(&invite))
|
||||
.into_any_element(),
|
||||
_ => div().into_any_element(),
|
||||
};
|
||||
let [walk, finish, skip, again] = [(); 4].map(|_| state.clone());
|
||||
let body = match finished {
|
||||
true => div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(format!("{} is ready.", name.read(cx).text()))
|
||||
.child(
|
||||
Button::new("onboarding-again", "Start over")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
change(&again, cx, |welcome| *welcome = Welcome::default())
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
false => OnboardingWizard::new("onboarding-wizard", steps, step)
|
||||
.content(content)
|
||||
.ready(ready)
|
||||
.on_step(move |to, _, cx| change(&walk, cx, |welcome| welcome.step = to))
|
||||
.on_finish(move |_, cx| change(&finish, cx, |welcome| welcome.finished = true))
|
||||
.on_skip(move |_, cx| change(&skip, cx, |welcome| welcome.finished = true))
|
||||
.into_any_element(),
|
||||
};
|
||||
section(
|
||||
"OnboardingWizard · WelcomeTour",
|
||||
"A first run in a few steps: where it stands over a bar in as many parts, each step's title and line, and Next once its fields are ready. The welcome tour that follows is overlays::Tour.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("onboarding-wizard", div().w(px(440.)).child(body)))
|
||||
}
|
||||
|
||||
/// The highlights demo: what was dismissed.
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
hotspot: bool,
|
||||
highlight: bool,
|
||||
}
|
||||
|
||||
pub fn highlights(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-seen", Seen::default, window, cx);
|
||||
let (spot_seen, card_seen) = (state.read(cx).hotspot, state.read(cx).highlight);
|
||||
let [spot, card, reset] = [(); 3].map(|_| state.clone());
|
||||
let theme = cx.theme();
|
||||
let tool =
|
||||
|id: &'static str, icon: IconName| IconButton::new(id, icon).variant(ButtonVariant::Ghost);
|
||||
let pin = div()
|
||||
.relative()
|
||||
.child(tool("onboarding-pin", IconName::Pin))
|
||||
.when(!spot_seen, |pin| {
|
||||
pin.child(
|
||||
div().absolute().top_neg_1p5().right_neg_1p5().child(probe(
|
||||
"onboarding-hotspot",
|
||||
Hotspot::new(
|
||||
"onboarding-hotspot",
|
||||
"Pin a view",
|
||||
"Keep the views you use most one press away.",
|
||||
)
|
||||
.on_dismiss(move |_, cx| change(&spot, cx, |seen| seen.hotspot = true)),
|
||||
)),
|
||||
)
|
||||
});
|
||||
let toolbar = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.p_1()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(tool("onboarding-search", IconName::Search))
|
||||
.child(pin)
|
||||
.child(tool("onboarding-share", IconName::Share2));
|
||||
let highlight = (!card_seen).then(|| {
|
||||
FeatureHighlight::new(
|
||||
"onboarding-split",
|
||||
IconName::Columns2,
|
||||
"Split view",
|
||||
"Work on two files side by side, each with its own history.",
|
||||
)
|
||||
.on_try(|_, _| log::info!("gallery: split view tried"))
|
||||
.on_dismiss(move |_, cx| change(&card, cx, |seen| seen.highlight = true))
|
||||
});
|
||||
let menu = List::new()
|
||||
.child(
|
||||
ListItem::new("onboarding-inbox", "Inbox")
|
||||
.leading(Icon::new(IconName::Inbox).size(IconSize::Sm)),
|
||||
)
|
||||
.child(
|
||||
ListItem::new("onboarding-automations", "Automations")
|
||||
.leading(Icon::new(IconName::Zap).size(IconSize::Sm))
|
||||
.trailing(Badge::new("New").tone(Tone::Accent)),
|
||||
);
|
||||
section(
|
||||
"Coachmark / Hotspot · FeatureHighlight / NewBadge",
|
||||
"A hotspot's rings call out something new, and a press or Enter opens its tip. A highlight names a feature with a New badge over it; the badge alone marks a new row. Coachmarks are overlays::Spotlight.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(div().w(px(240.)).flex().flex_col().gap_6().child(toolbar).child(menu))
|
||||
.child(div().w(px(360.)).children(highlight))
|
||||
.when(spot_seen || card_seen, |row| {
|
||||
row.child(
|
||||
Button::new("onboarding-show-again", "Show again")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| change(&reset, cx, |seen| *seen = Seen::default())),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Which setup tasks are done, and whether the list was hidden.
|
||||
struct Setup {
|
||||
done: [bool; 4],
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
pub fn checklist(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep(
|
||||
"onboarding-setup",
|
||||
|| Setup {
|
||||
done: [true, false, false, false],
|
||||
hidden: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (done, hidden) = (state.read(cx).done, state.read(cx).hidden);
|
||||
let [start, hide, reset] = [(); 3].map(|_| state.clone());
|
||||
const TASKS: [(&str, &str, &str); 4] = [
|
||||
(
|
||||
"profile",
|
||||
"Add your photo",
|
||||
"So your team knows who is who.",
|
||||
),
|
||||
("invite", "Invite a teammate", "Work is better shared."),
|
||||
(
|
||||
"connect",
|
||||
"Connect your calendar",
|
||||
"See meetings beside your tasks.",
|
||||
),
|
||||
(
|
||||
"shortcuts",
|
||||
"Learn three shortcuts",
|
||||
"Command K opens everything.",
|
||||
),
|
||||
];
|
||||
let tasks = TASKS
|
||||
.iter()
|
||||
.zip(done)
|
||||
.map(|((key, title, body), done)| SetupTask {
|
||||
key: (*key).into(),
|
||||
title: (*title).into(),
|
||||
body: (*body).into(),
|
||||
done,
|
||||
});
|
||||
let body = match hidden {
|
||||
true => Button::new("onboarding-setup-again", "Show the checklist")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
change(&reset, cx, |setup| {
|
||||
*setup = Setup {
|
||||
done: [true, false, false, false],
|
||||
hidden: false,
|
||||
}
|
||||
})
|
||||
})
|
||||
.into_any_element(),
|
||||
false => SetupChecklist::new("onboarding-setup", "Get started", tasks)
|
||||
.on_start(move |key, _, cx| {
|
||||
let at = TASKS
|
||||
.iter()
|
||||
.position(|(each, _, _)| *each == key.as_ref())
|
||||
.expect("a setup task");
|
||||
change(&start, cx, |setup| setup.done[at] = true)
|
||||
})
|
||||
.on_dismiss(move |_, cx| change(&hide, cx, |setup| setup.hidden = true))
|
||||
.into_any_element(),
|
||||
};
|
||||
section(
|
||||
"Checklist (first-run tasks)",
|
||||
"First things to do, counted in words and a bar. Start opens each; here it marks it done, and the check eases in. Once all are done, Hide puts it away.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("onboarding-setup", div().w(px(400.)).child(body)))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod flows;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 37,
|
||||
slug: "onboarding",
|
||||
title: "Onboarding & Help",
|
||||
summary: "A first run in steps, what is new called out, and first things to do.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("onboarding-wizard", 200.0, 167.0),
|
||||
Step::UpAt("onboarding-wizard", 200.0, 167.0),
|
||||
Step::Type("Acme"),
|
||||
Step::Key("tab"),
|
||||
Step::Key("enter"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("wizard-step-2"),
|
||||
Step::DownAt("onboarding-hotspot", 10.0, 10.0),
|
||||
Step::UpAt("onboarding-hotspot", 10.0, 10.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("hotspot-tip"),
|
||||
Step::Key("escape"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(flows::wizard(window, cx))
|
||||
.child(flows::highlights(window, cx))
|
||||
.child(flows::checklist(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
Reference in new issue
Block a user