T11a loading: spinner in six motions, skeletons that breathe, a shimmer cut to its corners, loading buttons and load more
This commit is contained in:
17 files changed
+1184
-28
No files matched your search
+43
-9
@@ -4,6 +4,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion::Spinner,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
|
||||
typography::keys::{Platform, keystroke_labels},
|
||||
@@ -119,6 +120,7 @@ pub struct Button {
|
||||
variant: ButtonVariant,
|
||||
size: ControlSize,
|
||||
disabled: bool,
|
||||
loading: bool,
|
||||
full_width: bool,
|
||||
pub(crate) slot: Option<Slot>,
|
||||
on_click: Option<ClickHandler>,
|
||||
@@ -135,6 +137,7 @@ impl Button {
|
||||
variant: ButtonVariant::default(),
|
||||
size: ControlSize::default(),
|
||||
disabled: false,
|
||||
loading: false,
|
||||
full_width: false,
|
||||
slot: None,
|
||||
on_click: None,
|
||||
@@ -178,6 +181,12 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Spins in place of its content, at the same width, and ignores presses. Tab still reaches it.
|
||||
pub fn loading(mut self, loading: bool) -> Self {
|
||||
self.loading = loading;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn full_width(mut self) -> Self {
|
||||
self.full_width = true;
|
||||
self
|
||||
@@ -201,6 +210,23 @@ impl RenderOnce for Button {
|
||||
let icon = |name| Icon::new(name).size(icon_size).color(tone.fg);
|
||||
let radius = theme.radius(Radius::Md);
|
||||
let hint = self.shortcut.as_ref().map(shortcut_text);
|
||||
let spinner = (self.id.clone(), "spinner");
|
||||
let parts = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.when(!self.label.is_empty(), |el| el.child(self.label))
|
||||
.when_some(self.trailing_icon, |el, name| el.child(icon(name)))
|
||||
.when_some(hint, |el, hint| {
|
||||
el.child(
|
||||
div()
|
||||
.pl_1()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(tone.fg.opacity(0.55))
|
||||
.child(hint),
|
||||
)
|
||||
});
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
@@ -224,22 +250,30 @@ impl RenderOnce for Button {
|
||||
.text_color(tone.fg)
|
||||
.bg(tone.bg)
|
||||
.when(self.full_width, |el| el.w_full())
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.when(!self.label.is_empty(), |el| el.child(self.label))
|
||||
.when_some(self.trailing_icon, |el, name| el.child(icon(name)))
|
||||
.when_some(hint, |el, hint| {
|
||||
el.child(
|
||||
.map(|el| {
|
||||
if !self.loading {
|
||||
return el.child(parts);
|
||||
}
|
||||
el.relative().child(parts.invisible()).child(
|
||||
div()
|
||||
.pl_1()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(tone.fg.opacity(0.55))
|
||||
.child(hint),
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new(spinner).size(icon_size).color(tone.fg)),
|
||||
)
|
||||
})
|
||||
.map(|el| {
|
||||
if self.disabled {
|
||||
return el.opacity(0.45).cursor_not_allowed();
|
||||
}
|
||||
if self.loading {
|
||||
return el
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default());
|
||||
}
|
||||
el.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.hover(|style| {
|
||||
|
||||
@@ -11,6 +11,9 @@ mod segmented;
|
||||
mod share;
|
||||
mod sheet;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use bars::{ActionBar, BulkActionBar};
|
||||
pub use button::{Button, ButtonVariant};
|
||||
pub use confirm::{ConfirmButton, ConfirmMode};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use gpui::{
|
||||
Context, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render,
|
||||
Styled, TestAppContext, VisualTestContext, Window,
|
||||
};
|
||||
|
||||
use super::Button;
|
||||
use crate::{
|
||||
primitives::{FocusNext, FocusScope},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// One button that counts its presses.
|
||||
struct Bench {
|
||||
root: FocusHandle,
|
||||
loading: bool,
|
||||
presses: usize,
|
||||
}
|
||||
|
||||
impl Render for Bench {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
FocusScope::new(&self.root).root().size_full().child(
|
||||
Button::new("save", "Save")
|
||||
.loading(self.loading)
|
||||
.on_click(move |_, _, cx| view.update(cx, |bench, _| bench.presses += 1)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_loading_button_ignores_presses_and_keeps_its_tab_stop(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Bench {
|
||||
root: cx.focus_handle(),
|
||||
loading: true,
|
||||
presses: 0,
|
||||
});
|
||||
let root = view.read_with(cx, |bench, _| bench.root.clone());
|
||||
cx.update(|window, _| window.focus(&root));
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |bench, _| bench.presses), 0);
|
||||
view.update(cx, |bench, cx| {
|
||||
bench.loading = false;
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
view.read_with(cx, |bench, _| bench.presses),
|
||||
1,
|
||||
"focus stayed on it"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user