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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 14:48:20 -04:00
1 parent 996daed6e4
commit 973f10a6dc
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)