Onboarding · Flows and highlights: focus that holds as the flow turns, a tip that closes

navigation::Wizard keeps one focus handle for Next and Finish, so a
second press on the step before last finishes; Back that reaches the
first step, and a finished step picked by key, hand focus to Next.
Popover::with_opener hands its content a close that gives focus back:
Got it closes the hotspot's tip before the owner hides it, and the
event details close before Edit and Delete run, so focus never lands
on a panel that left the tree. The wizard's count and the setup
checklist's tally name their selectors after their words, and the bar
takes the count's own value.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 14:48:20 -04:00
1 parent eea99b8e41
commit 1879f6f08b
15 files changed
+180 -54

No files matched your search

+2 -2
View File
@@ -77,8 +77,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it, and a button in a `SelectableList` row stops Space and Enter at its box (`mail::MailItem`'s star, `messaging::PinnedMessages`' Unpin).
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`).
- A button whose words and action change with a mode keeps one handle through `Button::focus_handle`, and the component moves focus to it or back to its field as the mode turns (`project::TimeTracker`).
- A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip).
- A flow in steps goes through `navigation::Wizard`: Back, Next, Finish and the step's motion live there. `onboarding::OnboardingWizard` leaves out its numbered steps (`headless`) and shows a count over a bar in as many parts.
- A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip). Its content gets a close that hands focus back: a press that ends the panel's work closes it before the owner acts, or a panel whose host leaves the tree strands focus (`onboarding::Hotspot`'s Got it, EventPopover's Edit and Delete).
- A flow in steps goes through `navigation::Wizard`: Back, Next, Finish and the step's motion live there. Next and Finish share one focus handle, and focus moves to it when Back reaches the first step or a finished step is picked. `onboarding::OnboardingWizard` leaves out its numbered steps (`headless`) and shows a count over a bar in as many parts.
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
- Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike.
- A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`).
+1
View File
@@ -409,3 +409,4 @@ Per-component lines live in `tasks/`. Tags there:
| T36b | 2 | PASS | |
| T36c | 1 | FAIL | a long quota name pushed its count past 280px; a month past 12 read as passed; the Amex shape, the address clearing, the clamped share and the paywall's benefits untested |
| T36c | 2 | PASS | |
| T37a | 1 | FAIL | the wizard's Next and Back dropped focus as the flow turned; Got it stranded focus on a closed tip; the words read and the bar's share untested apart from their selectors |
+4 -3
View File
@@ -14,7 +14,7 @@ use super::{
use crate::{
buttons::{Button, ButtonVariant},
data_display::color_mark,
forms::OnValue,
forms::{OnValue, Run},
overlays::Popover,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
@@ -107,17 +107,18 @@ impl RenderOnce for EventPopover {
.unwrap_or_else(|| format::system_zone("event popover"));
let (event, chip_zone) = (self.event.clone(), zone.clone());
let (id, chip_id) = (self.id.clone(), (self.id.clone(), "chip"));
let details = move |_: &mut Window, cx: &mut App| {
let details = move |close: Run, _: &mut Window, cx: &mut App| {
let theme = cx.theme();
let key = self.event.key.clone();
let action = |label: &'static str, variant, handler: Option<OnValue>| {
handler.map(|handler| {
let key = key.clone();
let (key, close) = (key.clone(), close.clone());
Button::new((self.id.clone(), label), label)
.variant(variant)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("event popover: {label} {key}");
close(window, cx);
handler(&key, window, cx)
})
})
+6
View File
@@ -90,6 +90,7 @@ fn popover(_: &Planning, owner: Entity<Planning>) -> gpui::AnyElement {
fn a_chip_opens_its_details_and_edit_names_it(cx: &mut TestAppContext) {
let (host, cx) = planning(popover, cx);
tab_to(1, cx);
let chip = cx.update(|window, cx| window.focused(cx));
press("space", cx);
assert!(
cx.debug_bounds("event-popover review").is_some(),
@@ -105,4 +106,9 @@ fn a_chip_opens_its_details_and_edit_names_it(cx: &mut TestAppContext) {
["edit review"],
"Tab reaches Delete, then Edit"
);
let focused = cx.update(|window, cx| window.focused(cx));
assert_eq!(
focused, chip,
"Edit closes the details and hands focus back to the chip"
);
}
+1 -1
View File
@@ -87,6 +87,6 @@ pub(crate) fn color_well(
.tooltip("Change the color")
.on_click(move |window, cx| toggle(window, cx))
},
move |_, _| ColorPicker::new((id, "picker"), color).on_change(on_pick),
move |_, _, _| ColorPicker::new((id, "picker"), color).on_change(on_pick),
)
}
+5 -1
View File
@@ -151,7 +151,11 @@ impl RenderOnce for ProgressBar {
.when_some(buffer, |lane, buffer| {
lane.child(bar(share(buffer)).bg(faint))
})
.child(bar(share(at)).bg(fill))
.child(
bar(share(at))
.bg(fill)
.debug_selector(move || format!("progress-fill-{ix}")),
)
})
};
div()
+28 -17
View File
@@ -10,7 +10,7 @@ use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, Run},
motion,
primitives::{FocusRing, Icon, IconName},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
@@ -208,7 +208,7 @@ impl RenderOnce for Steps {
}
}
/// Steps, the current step's content, then Back and Next. Next becomes Finish on the last step.
/// Steps, the current step's content, then Back and Next. Next becomes Finish on the last step, keeping its focus; focus moves to it when Back reaches the first step or a finished step is picked.
#[derive(IntoElement)]
pub struct Wizard {
id: ElementId,
@@ -293,7 +293,9 @@ impl RenderOnce for Wizard {
on_step(to, window, cx);
})
};
let advance = tab_stop((id.clone(), "advance").into(), true, window, cx);
let (back, next, jump) = (step.clone(), step.clone(), step);
let (to_advance, jumped) = (advance.clone(), advance.clone());
let finish = {
let id = id.clone();
move |window: &mut Window, cx: &mut App| {
@@ -315,17 +317,17 @@ impl RenderOnce for Wizard {
},
)
});
let advance = if current == last {
Button::new((id.clone(), "finish"), "Finish")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| finish(window, cx))
} else {
Button::new((id.clone(), "next"), "Next")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| next(current + 1, window, cx))
};
let forward = Button::new(
(id.clone(), "advance"),
if current == last { "Finish" } else { "Next" },
)
.primary()
.disabled(!self.ready)
.focus_handle(&advance)
.on_click(move |_, window, cx| match current == last {
true => finish(window, cx),
false => next(current + 1, window, cx),
});
div()
.id(self.id.clone())
.flex()
@@ -334,8 +336,12 @@ impl RenderOnce for Wizard {
.w_full()
.when(!self.headless, |wizard| {
wizard.child(
Steps::new((id.clone(), "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
Steps::new((id.clone(), "steps"), self.steps, current).on_select(
move |to, window, cx| {
window.focus(&jumped);
jump(to, window, cx)
},
),
)
})
.children(content)
@@ -348,9 +354,14 @@ impl RenderOnce for Wizard {
Button::new((id.clone(), "back"), "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
.on_click(move |_, window, cx| {
if current == 1 {
window.focus(&to_advance);
}
back(current - 1, window, cx)
}),
)
.child(advance),
.child(forward),
)
}
}
+18
View File
@@ -155,6 +155,24 @@ fn the_wizard_walks_forward_back_and_finishes(cx: &mut TestAppContext) {
assert!(view.read_with(cx, |view, _| view.done));
}
/// On the last step the first stop is the first finished step; picking it hands focus to Next, so Enter walks on from there.
#[gpui::test]
fn a_picked_step_hands_focus_to_next(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Flow {
step: 2,
done: false,
});
cx.update(|window, _| {
window.blur();
window.focus_next();
});
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 0);
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 1);
}
struct Docs {
chosen: SharedString,
clicked: Option<SharedString>,
+6 -2
View File
@@ -82,6 +82,7 @@ impl RenderOnce for SetupChecklist {
self.tasks.iter().filter(|task| task.done).count(),
self.tasks.len(),
);
let tally = format!("{done} of {all} done");
let turns: Vec<usize> = self
.tasks
.iter()
@@ -203,11 +204,14 @@ impl RenderOnce for SetupChecklist {
)
.child(
tabular(div())
.debug_selector(move || format!("setup-{done}-of-{all}"))
.debug_selector({
let tally = tally.clone();
move || format!("setup-{tally}")
})
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("{done} of {all} done")),
.child(tally),
),
)
.child(
+3 -2
View File
@@ -73,7 +73,7 @@ impl Hotspot {
}
}
/// Runs on Got it; the owner stops showing the hotspot.
/// Runs on Got it, once the tip has closed and handed focus back; the owner stops showing the hotspot.
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
@@ -94,7 +94,7 @@ impl RenderOnce for Hotspot {
id: dot.into(),
toggle,
},
move |_, cx| {
move |close: Run, _: &mut Window, cx: &mut App| {
let theme = cx.theme();
let prose =
|text: SharedString| div().flex().child(div().flex_1().min_w_0().child(text));
@@ -114,6 +114,7 @@ impl RenderOnce for Hotspot {
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("hotspot: dismissed");
close(window, cx);
dismiss(window, cx)
}),
),
+80 -9
View File
@@ -1,23 +1,26 @@
use gpui::{
AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled,
TestAppContext, VisualTestContext, Window, div, px,
AnyElement, App, Context, Entity, FocusHandle, IntoElement, KeyBinding, ParentElement, Render,
Styled, TestAppContext, VisualTestContext, Window, div, px,
};
use super::{
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
};
use crate::{
buttons::Button,
forms,
primitives::{FocusNext, IconName},
primitives::{FocusNext, FocusScope, IconName},
theme::Theme,
};
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
/// A view that shows one onboarding part, keeps what it heard, and holds the owner's step and readiness.
/// A view that shows one onboarding part, keeps what it heard, and holds the owner's step, readiness and what it hid, with the root focus an app keeps.
struct Bench {
part: Part,
said: Vec<String>,
root: FocusHandle,
hidden: bool,
step: usize,
ready: bool,
all_done: bool,
@@ -39,9 +42,11 @@ fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTest
forms::bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (host, cx) = cx.add_window_view(|_, _| Bench {
let (host, cx) = cx.add_window_view(|_, cx| Bench {
part,
said: Vec::new(),
root: cx.focus_handle(),
hidden: false,
step: 0,
ready: true,
all_done: false,
@@ -120,13 +125,23 @@ fn wizard(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
#[gpui::test]
fn the_wizard_walks_on_and_back_and_finishes(cx: &mut TestAppContext) {
let (host, cx) = bench(wizard, cx);
assert!(cx.debug_bounds("onboarding-step-1-of-3").is_some());
assert!(cx.debug_bounds("onboarding-Step 1 of 3").is_some());
let (first, second) = (
cx.debug_bounds("progress-fill-0")
.expect("the bar's first part"),
cx.debug_bounds("progress-fill-1")
.expect("the bar's second part"),
);
assert!(
first.size.width > px(0.0) && second.size.width == px(0.0),
"the first of three parts is full: {first:?}, {second:?}"
);
tab(2, cx);
tap("space", cx);
tab(2, cx);
tap("space", cx);
edit(&host, cx, |bench| bench.step = 2);
assert!(cx.debug_bounds("onboarding-step-3-of-3").is_some());
assert!(cx.debug_bounds("onboarding-Step 3 of 3").is_some());
tab(3, cx);
tap("space", cx);
tab(1, cx);
@@ -144,6 +159,28 @@ fn next_rests_until_the_step_is_ready(cx: &mut TestAppContext) {
assert_eq!(said(&host, cx), ["skip"]);
}
/// On the step before last, the stops are Skip, Back and Next: Next turns to Finish under focus, so a second press finishes.
#[gpui::test]
fn next_keeps_focus_as_it_turns_to_finish(cx: &mut TestAppContext) {
let (host, cx) = bench(wizard, cx);
edit(&host, cx, |bench| bench.step = 1);
tab(3, cx);
tap("space", cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["step 2", "finish"]);
}
/// Back from the second step leaves Back at rest on the first, so focus moves to Next, and a press walks on again.
#[gpui::test]
fn back_to_the_first_step_hands_focus_to_next(cx: &mut TestAppContext) {
let (host, cx) = bench(wizard, cx);
edit(&host, cx, |bench| bench.step = 1);
tab(2, cx);
tap("space", cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["step 0", "step 1"]);
}
fn hotspot(_: &Bench, owner: Entity<Bench>) -> AnyElement {
Hotspot::new("pins", "Pin a view", "Keep it one press away.")
.on_dismiss(move |_, cx| say(&owner, "seen".into(), cx))
@@ -172,6 +209,40 @@ fn a_hotspot_opens_its_tip_from_the_keyboard_and_is_dismissed(cx: &mut TestAppCo
);
}
fn hidden_hotspot(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let after = owner.clone();
let spot = (!bench.hidden).then(|| {
Hotspot::new("pins", "Pin a view", "Keep it one press away.").on_dismiss(move |_, cx| {
owner.update(cx, |bench, cx| {
bench.said.push("seen".into());
bench.hidden = true;
cx.notify();
})
})
});
FocusScope::new(&bench.root)
.root()
.children(spot)
.child(
Button::new("after", "After").on_click(move |_, _, cx| say(&after, "after".into(), cx)),
)
.into_any_element()
}
/// Got it closes the tip before the owner hides the dot, so the root keeps focus and Tab reaches the next stop.
#[gpui::test]
fn after_got_it_tab_reaches_the_next_stop(cx: &mut TestAppContext) {
let (host, cx) = bench(hidden_hotspot, cx);
cx.update(|window, _| window.activate_window());
tab(1, cx);
tap("enter", cx);
tap("tab", cx);
tap("space", cx);
tap("tab", cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["seen", "after"]);
}
fn highlight(_: &Bench, owner: Entity<Bench>) -> AnyElement {
let later = owner.clone();
FeatureHighlight::new(
@@ -222,13 +293,13 @@ fn checklist(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
#[gpui::test]
fn a_setup_checklist_starts_what_is_left_and_hides_when_done(cx: &mut TestAppContext) {
let (host, cx) = bench(checklist, cx);
assert!(cx.debug_bounds("setup-1-of-3").is_some());
assert!(cx.debug_bounds("setup-1 of 3 done").is_some());
tab(2, cx);
tap("space", cx);
tab(3, cx);
tap("space", cx);
edit(&host, cx, |bench| bench.all_done = true);
assert!(cx.debug_bounds("setup-3-of-3").is_some());
assert!(cx.debug_bounds("setup-3 of 3 done").is_some());
tab(1, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["start connect", "start invite", "hide"]);
+9 -7
View File
@@ -106,6 +106,8 @@ impl RenderOnce for OnboardingWizard {
.unwrap_or_else(|| panic!("onboarding wizard {id:?} has no on_finish"));
let theme = cx.theme();
let (current, count) = (self.current, self.steps.len());
let reached = current + 1;
let place = format!("Step {reached} of {count}");
let skip = self.on_skip.map(|run| {
Button::new((id.clone(), "skip"), "Skip for now")
.variant(ButtonVariant::Ghost)
@@ -123,10 +125,13 @@ impl RenderOnce for OnboardingWizard {
.gap_2()
.child(
div()
.debug_selector(move || format!("onboarding-step-{}-of-{count}", current + 1))
.debug_selector({
let place = place.clone();
move || format!("onboarding-{place}")
})
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("Step {} of {count}", current + 1)),
.child(place),
)
.children(skip.map(|skip| div().flex_none().child(skip)));
let step = &self.steps[current];
@@ -172,11 +177,8 @@ impl RenderOnce for OnboardingWizard {
.gap_6()
.child(
div().flex().flex_col().gap_2().child(head).child(
ProgressBar::new(
(id.clone(), "progress"),
(current + 1) as f32 / count as f32,
)
.segments(count),
ProgressBar::new((id.clone(), "progress"), reached as f32 / count as f32)
.segments(count),
),
)
.child(wizard)
+14 -7
View File
@@ -12,7 +12,7 @@ use crate::{
theme::ControlSize,
};
type Content = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type Content = Box<dyn FnOnce(Run, &mut Window, &mut App) -> AnyElement>;
type Own = Box<dyn FnOnce(Run) -> AnyElement>;
/// A popover's own button: its label and look.
@@ -73,20 +73,22 @@ impl Popover {
variant: ButtonVariant::Secondary,
size: ControlSize::default(),
}),
content: Box::new(move |window, cx| content(window, cx).into_any_element()),
content: Box::new(move |_, window, cx| content(window, cx).into_any_element()),
}
}
/// A panel that opens from an element of the owner's: `opener` builds it with the run that opens and closes the panel.
/// A panel that opens from an element of the owner's: `opener` builds it with the run that opens and closes the panel, and `content` gets the run that closes it and hands focus back, for a press that ends the panel's work.
pub fn with_opener<T: IntoElement, E: IntoElement>(
id: impl Into<ElementId>,
opener: impl FnOnce(Run) -> T + 'static,
content: impl FnOnce(&mut Window, &mut App) -> E + 'static,
content: impl FnOnce(Run, &mut Window, &mut App) -> E + 'static,
) -> Self {
Self {
id: id.into(),
opener: Opener::Own(Box::new(move |toggle| opener(toggle).into_any_element())),
content: Box::new(move |window, cx| content(window, cx).into_any_element()),
content: Box::new(move |close, window, cx| {
content(close, window, cx).into_any_element()
}),
}
}
@@ -179,9 +181,14 @@ impl RenderOnce for Popover {
close(&state, window, cx);
return host;
}
let (escape, out, tall) = (state.clone(), state.clone(), state.clone());
let (escape, out, tall, done) =
(state.clone(), state.clone(), state.clone(), state.clone());
let (anchor, height) = (state.read(cx).host, state.read(cx).height);
let body = (self.content)(window, cx);
let body = (self.content)(
Rc::new(move |window, cx| close(&done, window, cx)),
window,
cx,
);
let panel = div()
.id((self.id.clone(), "panel"))
.relative()
+1 -1
View File
@@ -430,5 +430,5 @@ fn a_tall_dialog_body_keeps_its_height_and_scrolls(cx: &mut TestAppContext) {
#[test]
#[should_panic(expected = "an icon is for its own button")]
fn a_popover_opened_by_its_owner_takes_no_icon() {
let _ = Popover::with_opener("own", |_| div(), |_, _| div()).icon(IconName::Clock);
let _ = Popover::with_opener("own", |_| div(), |_, _, _| div()).icon(IconName::Clock);
}
+2 -2
View File
@@ -8,7 +8,7 @@ use gpui::{
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::color_mark,
forms::{ColorPalette, Enter, FormError, InlineEdit, Input, OnValue, TextInput},
forms::{ColorPalette, Enter, FormError, InlineEdit, Input, OnValue, Run, TextInput},
mail::Label,
overlays::Popover,
primitives::{Icon, IconName},
@@ -149,7 +149,7 @@ impl RenderOnce for LabelManager {
let swatch_id = (id.clone(), format!("hue-{key}"));
let palette = {
let (id, key, on_recolor) = (id.clone(), key.clone(), self.on_recolor.clone());
move |_: &mut Window, _: &mut App| {
move |_: Run, _: &mut Window, _: &mut App| {
let colors = HUE_NAMES
.iter()
.zip(chart)