modal: in-app dialog for confirms and import errors

ModalState carries title/message/action as RwSignals; the action is
read at click time so a late confirm wins. Overlay click and Cancel
close; the confirm button runs the stored action. Edition-2024 note:
method calls on non-Copy captures move by value inside nested move
closures, so handlers touch state.open/state.action fields directly.
This commit is contained in:
yaroslav k 2026-08-16 11:28:19 +03:00
parent 043977cc1f
commit 0c2eda7621
4 changed files with 161 additions and 0 deletions

View File

@ -21,6 +21,7 @@ pub mod nav;
pub mod inventory;
pub mod numput;
pub mod item;
pub mod modal;
/// Character page (route `/`). Stub: renders a placeholder until
/// ROADMAP phase 2.

106
src/components/modal.rs Normal file
View File

@ -0,0 +1,106 @@
//! In-app modal dialogs: confirmations and error messages.
//!
//! `ModalState` is created in `App` and provided through Leptos context;
//! the header's Save/Load/Clear handlers open dialogs and [`Modal`]
//! renders them. The dialog language is Russian.
use std::sync::Arc;
use leptos::prelude::*;
/// Shared state of the modal overlay.
///
/// `open` decides visibility; `title`/`message`/`cancel_label` and
/// `confirm_label` (None for error dialogs) drive the text; `action` runs
/// when the confirm button is clicked.
#[derive(Clone)]
pub struct ModalState {
open: RwSignal<bool>,
title: RwSignal<String>,
message: RwSignal<String>,
cancel_label: RwSignal<String>,
confirm_label: RwSignal<Option<String>>,
action: RwSignal<Arc<dyn Fn() + Send + Sync>>,
}
impl ModalState {
/// Creates a closed modal with empty content.
pub fn new () -> Self {
Self {
open: RwSignal::new(false),
title: RwSignal::new(String::new()),
message: RwSignal::new(String::new()),
cancel_label: RwSignal::new(String::new()),
confirm_label: RwSignal::new(None),
action: RwSignal::new(Arc::new(|| {})),
}
}
/// Opens a confirmation dialog: Cancel plus the given confirm button.
pub fn confirm (
&self,
title: impl Into<String>,
message: impl Into<String>,
confirm_label: impl Into<String>,
action: impl Fn() + Send + Sync + 'static,
) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Отмена".to_string());
self.confirm_label.set(Some(confirm_label.into()));
self.action.set(Arc::new(action));
self.open.set(true);
}
/// Opens an error dialog with a single Close button.
pub fn error (&self, title: impl Into<String>, message: impl Into<String>) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Закрыть".to_string());
self.confirm_label.set(None);
self.open.set(true);
}
/// Closes the dialog.
pub fn close (&self) {
self.open.set(false);
}
}
/// Renders the modal overlay; empty when no dialog is open.
///
/// Clicking the overlay background closes the dialog; clicks inside the
/// dialog do not. The confirm button runs the action captured in
/// `ModalState` (read at click time, so a late `confirm` wins).
#[component]
pub fn Modal (state: ModalState) -> impl IntoView {
view! {
<Show when=move || state.open.get()>
<div class="modal-overlay" on:click=move |_| state.open.set(false)>
<div
class="modal"
on:click=move |ev: leptos::ev::MouseEvent| ev.stop_propagation()
>
<h3>{move || state.title.get()}</h3>
<p>{move || state.message.get()}</p>
<div class="modal-actions">
<button class="modal-cancel" on:click=move |_| state.open.set(false)>
{move || state.cancel_label.get()}
</button>
<Show when=move || state.confirm_label.get().is_some()>
<button
class="modal-confirm"
on:click=move |_| {
(state.action.get())();
state.open.set(false);
}
>
{move || state.confirm_label.get().unwrap_or_default()}
</button>
</Show>
</div>
</div>
</div>
</Show>
}
}

53
style/_modal.scss Normal file
View File

@ -0,0 +1,53 @@
// Modal dialog: dark overlay, centered card, crimson confirm button.
@use 'mixins';
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.75);
}
.modal {
max-width: 480px;
padding: 24px 32px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8);
h3 {
margin: 0 0 12px;
@include mixins.heading;
font-weight: 900;
}
p {
margin: 0 0 20px;
color: rgba(255, 255, 255, 0.75);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.modal-cancel {
@include mixins.button;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.8);
padding: 6px 18px;
cursor: pointer;
}
button.modal-confirm {
@include mixins.button;
padding: 6px 18px;
cursor: pointer;
}

View File

@ -6,6 +6,7 @@
@use 'sidebar';
@use 'nav';
@use 'inventory';
@use 'modal';
@use 'vars';