From fd58296ee9ec98cfa63b60f116a6e78ff07ec36a Mon Sep 17 00:00:00 2001 From: Chung Wong Date: Wed, 15 Apr 2026 14:23:11 +1000 Subject: [PATCH] feat: add Popconfirm component Wraps Popover with a confirmation message and Yes/No buttons. Useful for destructive actions like delete. --- demo/src/app.rs | 1 + demo/src/pages/components.rs | 5 ++ demo_markdown/src/lib.rs | 1 + thaw/src/lib.rs | 2 + thaw/src/popconfirm/docs/mod.md | 101 ++++++++++++++++++++++++++++ thaw/src/popconfirm/mod.rs | 102 +++++++++++++++++++++++++++++ thaw/src/popconfirm/popconfirm.css | 20 ++++++ thaw/src/popover/docs/mod.md | 1 + thaw/src/popover/mod.rs | 32 ++++++--- 9 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 thaw/src/popconfirm/docs/mod.md create mode 100644 thaw/src/popconfirm/mod.rs create mode 100644 thaw/src/popconfirm/popconfirm.css diff --git a/demo/src/app.rs b/demo/src/app.rs index b527f988..2a314e88 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -98,6 +98,7 @@ fn TheRouter() -> impl IntoView { + diff --git a/demo/src/pages/components.rs b/demo/src/pages/components.rs index 52e8bf65..7120b81a 100644 --- a/demo/src/pages/components.rs +++ b/demo/src/pages/components.rs @@ -359,6 +359,11 @@ pub(crate) fn gen_nav_data() -> Vec { value: "/components/persona", label: "Persona", }, + NavItemOption { + group: None, + value: "/components/popconfirm", + label: "Popconfirm", + }, NavItemOption { group: None, value: "/components/popover", diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index 64e25b7f..7d1f69e8 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -62,6 +62,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "NavMdPage" => "../../thaw/src/nav/docs/mod.md", "PaginationMdPage" => "../../thaw/src/pagination/docs/mod.md", "PersonaMdPage" => "../../thaw/src/persona/docs/mod.md", + "PopconfirmMdPage" => "../../thaw/src/popconfirm/docs/mod.md", "PopoverMdPage" => "../../thaw/src/popover/docs/mod.md", "ProgressBarMdPage" => "../../thaw/src/progress_bar/docs/mod.md", "RadioMdPage" => "../../thaw/src/radio/docs/mod.md", diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 37a675fa..d47c75f3 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -39,6 +39,7 @@ mod message_bar; mod nav; mod pagination; mod persona; +mod popconfirm; mod popover; mod progress_bar; mod radio; @@ -100,6 +101,7 @@ pub use message_bar::*; pub use nav::*; pub use pagination::*; pub use persona::*; +pub use popconfirm::*; pub use popover::*; pub use progress_bar::*; pub use radio::*; diff --git a/thaw/src/popconfirm/docs/mod.md b/thaw/src/popconfirm/docs/mod.md new file mode 100644 index 00000000..9aca3b1e --- /dev/null +++ b/thaw/src/popconfirm/docs/mod.md @@ -0,0 +1,101 @@ +# Popconfirm + +A popover that asks for confirmation before executing an action. + +### Basic + +```rust demo +let toaster = ToasterInjection::expect_context(); + +let on_confirm = Callback::new(move |_| { + toaster.dispatch_toast(move || view! { + + "Confirmed" + + }, ToastOptions::default().with_intent(ToastIntent::Success).with_position(ToastPosition::Top)); +}); + +let on_cancel = Callback::new(move |_| { + toaster.dispatch_toast(move || view! { + + "Cancelled" + + }, ToastOptions::default().with_intent(ToastIntent::Warning).with_position(ToastPosition::Top)); +}); + +view! { + + + +} +``` + +### Custom Button Text + +```rust demo +let toaster = ToasterInjection::expect_context(); + +let on_confirm = Callback::new(move |_| { + toaster.dispatch_toast(move || view! { + + "Item removed" + + }, ToastOptions::default().with_intent(ToastIntent::Success).with_position(ToastPosition::Top)); +}); + +view! { + + + +} +``` + +### Position + +```rust demo +let on_confirm = Callback::new(move |_| {}); + +view! { + + + + + + + + + + + +} +``` + +### Popconfirm Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `MaybeProp` | `Default::default()` | | +| title | `String` | `""` | Title shown in the popover. | +| description | `String` | `""` | Description shown below the title. | +| position | `PopoverPosition` | `PopoverPosition::Top` | Configures the position of the popover. | +| appearance | `MaybeProp` | `Default::default()` | A popover can appear styled with brand or inverted. | +| trigger_type | `PopoverTriggerType` | `PopoverTriggerType::Click` | Action that displays the popconfirm. | +| size | `Signal` | `PopoverSize::Medium` | Size of the popover. | +| on_confirm | `Callback<()>` | | Called when the user clicks the confirm button. | +| on_cancel | `Option>` | `None` | Called when the user clicks the cancel button. | +| on_open | `Option` | `None` | Listen for popover open events. | +| on_close | `Option` | `None` | Listen for popover close events. | +| ok_text | `&'static str` | `"Yes"` | Text for the confirm button. | +| cancel_text | `&'static str` | `"No"` | Text for the cancel button. | +| children | `T: AddAnyAttr + IntoView + Send + 'static` | | The trigger element. | diff --git a/thaw/src/popconfirm/mod.rs b/thaw/src/popconfirm/mod.rs new file mode 100644 index 00000000..cc01373a --- /dev/null +++ b/thaw/src/popconfirm/mod.rs @@ -0,0 +1,102 @@ +use crate::{ + Button, ButtonAppearance, ButtonSize, Popover, PopoverAppearance, PopoverPosition, PopoverSize, + PopoverTrigger, PopoverTriggerType, +}; +use leptos::prelude::*; +use thaw_utils::{mount_style, BoxCallback, Model}; + +/// A popover that asks for confirmation before executing an action. +/// +/// ```rust +/// +/// +/// +/// ``` +#[component] +pub fn Popconfirm( + #[prop(optional, into)] class: MaybeProp, + /// Title shown in the popover. + #[prop(optional, into)] + title: String, + /// Description shown below the title. + #[prop(optional, into)] + description: String, + /// Called when the user clicks the confirm button. + #[prop(into)] + on_confirm: Callback<()>, + /// Called when the user clicks the cancel button. + #[prop(optional, into)] + on_cancel: Option>, + /// Configures the position of the popover. + #[prop(optional)] + position: PopoverPosition, + /// A popover can appear styled with brand or inverted. + #[prop(optional, into)] + appearance: MaybeProp, + /// Action that displays the popconfirm. Defaults to Click. + #[prop(default = PopoverTriggerType::Click)] + trigger_type: PopoverTriggerType, + #[prop(optional, into)] size: Signal, + #[prop(optional, into)] on_open: Option, + #[prop(optional, into)] on_close: Option, + /// Text for the confirm button. + #[prop(default = "Yes")] + ok_text: &'static str, + /// Text for the cancel button. + #[prop(default = "No")] + cancel_text: &'static str, + /// The trigger element (e.g. a Button). + children: TypedChildren, +) -> impl IntoView { + mount_style("popconfirm", include_str!("./popconfirm.css")); + + let is_open: Model = RwSignal::new(false).into(); + + let on_yes = move |_| { + is_open.set(false); + on_confirm.run(()); + }; + + let on_no = move |_| { + is_open.set(false); + if let Some(cb) = &on_cancel { + cb.run(()); + } + }; + + let on_open = on_open.unwrap_or_else(|| BoxCallback::new(|| {})); + let on_close = on_close.unwrap_or_else(|| BoxCallback::new(|| {})); + + view! { + + {(!title.is_empty()).then(|| view! { +
{title}
+ })} + {(!description.is_empty()).then(|| view! { +
{description}
+ })} +
+ + +
+
+ } +} diff --git a/thaw/src/popconfirm/popconfirm.css b/thaw/src/popconfirm/popconfirm.css new file mode 100644 index 00000000..d686982a --- /dev/null +++ b/thaw/src/popconfirm/popconfirm.css @@ -0,0 +1,20 @@ +.thaw-popconfirm__title { + font-size: var(--fontSizeBase300); + font-weight: var(--fontWeightSemibold); + line-height: var(--lineHeightBase300); +} + +.thaw-popconfirm__description { + font-size: var(--fontSizeBase200); + font-weight: var(--fontWeightRegular); + line-height: var(--lineHeightBase200); + color: var(--colorNeutralForeground2); + margin-top: 4px; +} + +.thaw-popconfirm__actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} diff --git a/thaw/src/popover/docs/mod.md b/thaw/src/popover/docs/mod.md index 56cdfe9e..b7453472 100644 --- a/thaw/src/popover/docs/mod.md +++ b/thaw/src/popover/docs/mod.md @@ -179,6 +179,7 @@ view! { | appearance | `MaybeProp` | `Default::default()` | A popover can appear styled with brand or inverted. When not specified, the default style is used. | | trigger_type | `PopoverTriggerType` | `PopoverTriggerType::Hover` | Action that displays the popover. | | popover_trigger | slot `PopoverTrigger` | | The element or component that triggers popover. | +| open | `Option>` | `None` | Controls the open state of the popover programmatically. | | on_open | `Option` | `None` | Listen for popover open events. | | on_close | `Option` | `None` | Listen for popover close events. | | children | `Children` | | | diff --git a/thaw/src/popover/mod.rs b/thaw/src/popover/mod.rs index c8e6cc1b..71ef853d 100644 --- a/thaw/src/popover/mod.rs +++ b/thaw/src/popover/mod.rs @@ -12,7 +12,7 @@ use leptos::{ }; use std::time::Duration; use thaw_components::{Follower, FollowerArrow}; -use thaw_utils::{class_list, mount_style, on_click_outside, BoxCallback}; +use thaw_utils::{class_list, mount_style, on_click_outside, BoxCallback, Model}; #[component] pub fn Popover( @@ -30,6 +30,10 @@ pub fn Popover( #[prop(optional, into)] appearance: MaybeProp, #[prop(optional, into)] size: Signal, + /// Controls the open state of the popover. When provided, the popover + /// becomes controlled and can be opened/closed programmatically. + #[prop(optional, into)] + open: Option>, #[prop(optional, into)] on_open: Option, #[prop(optional, into)] on_close: Option, children: Children, @@ -40,7 +44,21 @@ where mount_style("popover", include_str!("./popover.css")); let popover_ref = NodeRef::::new(); - let is_show_popover = RwSignal::new(false); + let is_show_popover = RwSignal::new(open.map(|m| m.get_untracked()).unwrap_or(false)); + + // Sync: model → internal signal + if let Some(model) = open { + Effect::new(move || { + is_show_popover.set(model.get()); + }); + } + + let set_show = move |val: bool| { + is_show_popover.set(val); + if let Some(model) = open { + model.set(val); + } + }; let show_popover_handle = StoredValue::new(None::); if on_open.is_some() || on_close.is_some() { @@ -72,7 +90,7 @@ where handle.clear(); } }); - is_show_popover.set(true); + set_show(true); }; let on_mouse_leave = move |_| { if trigger_type != PopoverTriggerType::Hover { @@ -84,7 +102,7 @@ where } *handle = set_timeout_with_handle( move || { - is_show_popover.set(false); + set_show(false); }, Duration::from_millis(100), ) @@ -118,15 +136,13 @@ where }; Some(vec![popover_el.into(), trigger_el]) }, - move || is_show_popover.set(false), + move || set_show(false), ); Either::Left( trigger_children .add_any_attr(node_ref(trigger_ref)) .add_any_attr(on(ev::click, move |_| { - is_show_popover.update(|show| { - *show = !*show; - }); + set_show(!is_show_popover.get_untracked()); })), ) }