src/components/feedback.rs
//! The console's shared feedback vocabulary: status badges, error/empty/loading
//! states, and the confirmation dialog for irreversible actions. One rendering
//! per state kind — views compose these instead of re-inventing them.
use leptos::prelude::*;
/// Semantic tone — never color alone: every badge carries its text. The enum
/// and its class mapping live in `util` (pure, natively unit-tested); this
/// re-export keeps `components::feedback::Tone` the console's one name for it.
pub use crate::util::Tone;
#[component]
pub fn Badge(tone: Tone, #[prop(into)] label: String) -> impl IntoView {
view! { <span class=tone.class()>{label}</span> }
}
#[component]
pub fn Loading(#[prop(optional, into)] what: String) -> impl IntoView {
let text = if what.is_empty() {
"loading…".to_string()
} else {
format!("loading {what}…")
};
view! { <p class="pending" role="status">{text}</p> }
}
/// An error is a sentence, not a color: the server's message rides verbatim.
#[component]
pub fn ErrorText(#[prop(into)] context: String, #[prop(into)] message: String) -> impl IntoView {
view! { <p class="err" role="alert">{format!("{context}: {message}")}</p> }
}
/// An empty state says what "empty" MEANS here — a blank table never speaks
/// for itself, and for some backends an empty listing is not "no data".
#[component]
pub fn EmptyState(#[prop(into)] message: String) -> impl IntoView {
view! { <p class="pending">{message}</p> }
}
/// One failed call, rendered from the unified state model: a kind-specific
/// summary FIRST (auth needed / permission denied / unsupported / backend
/// unavailable are different situations demanding different next steps),
/// then the server's verbatim message, the status-code name, the timestamp,
/// and a retry button when the caller says retrying is safe (reads only —
/// a mutation is never blind-retried from here).
#[component]
pub fn FailBox(
#[prop(into)] context: String,
#[prop(into)] fail: Signal<crate::grpc::Fail>,
#[prop(optional)] retry: Option<Callback<()>>,
) -> impl IntoView {
view! {
<div class="failbox" role="alert">
{move || {
let f = fail.get();
// Both the sentence and whether a login link belongs beside it
// are pure decisions on the status code (util, unit-tested).
let summary = crate::util::fail_summary(f.code);
let hint: Option<AnyView> = crate::util::fail_suggests_login(f.code)
.then(|| view! { <a href="/account">"log in"</a> }.into_any());
view! {
<p class="fail-summary">
<strong>{format!("{context}: {summary}")}</strong>
{hint.map(|h| view! { <span>" — "{h}</span> })}
</p>
<p class="fail-detail mono">
{format!("{} · {:?} · {}", f.message, f.code, f.at)}
</p>
}
}}
{retry.map(|r| view! {
<button class="quiet" on:click=move |_| r.run(())>"Retry"</button>
})}
</div>
}
}
/// Confirmation for irreversible actions. The consequence text is the point:
/// it must say what cannot be undone, in the domain's own words. The confirm
/// button disarms while the action runs (double-click safe) — and the caller
/// keeps its own busy-guard, because THIS dialog is UI, not the safety.
///
/// `confirm_phrase` adds a type-to-confirm gate for the most destructive
/// operations: the confirm button stays disabled until the operator types the
/// exact target name. It defends against the wrong-target mistake (right
/// intent, wrong repository) that a plain OK cannot catch. Reactive, because
/// the target can change while the dialog is closed; the typed text is
/// cleared on every open so a previous answer can never carry over.
///
/// A11y: focus moves into the dialog on open (the phrase input when there is
/// one — it is inert until the name matches — otherwise Cancel, the safe
/// default), Tab cycles between the dialog's focusable elements, Escape
/// cancels (unless busy), and focus returns to the opener on close.
#[component]
pub fn ConfirmDialog(
open: RwSignal<bool>,
#[prop(into)] title: String,
#[prop(into)] consequence: String,
#[prop(into)] confirm_label: String,
busy: RwSignal<bool>,
on_confirm: Callback<()>,
#[prop(optional, into)] confirm_phrase: Option<Signal<String>>,
children: ChildrenFn,
) -> impl IntoView {
// The element that had focus before the dialog opened — restored on close.
let restore_to: StoredValue<Option<web_sys::HtmlElement>> = StoredValue::new(None);
// Mount-reactive handles: NodeRef.get() flips to Some when the element
// actually exists, so the focus move cannot race the render (an effect
// polling querySelector could, and did).
let cancel_ref: NodeRef<leptos::html::Button> = NodeRef::new();
let phrase_ref: NodeRef<leptos::html::Input> = NodeRef::new();
let typed = RwSignal::new(String::new());
// The gate: no phrase demanded → armed; phrase demanded → armed only on an
// exact match (see util::confirm_phrase_matches, unit-tested).
let armed = Memo::new(move |_| match confirm_phrase {
None => true,
Some(p) => crate::util::confirm_phrase_matches(&typed.get(), &p.get()),
});
Effect::new(move |_| {
use wasm_bindgen::JsCast;
if open.get() {
// A fresh dialog always starts from an unarmed, empty state.
typed.set(String::new());
restore_to.set_value(
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()),
);
} else if let Some(el) = restore_to.get_value() {
let _ = el.focus();
restore_to.set_value(None);
}
});
Effect::new(move |_| {
// Prefer the phrase input: it is where the work is, and it cannot
// trigger anything — the confirm button is disabled until it matches.
if let Some(input) = phrase_ref.get() {
let _ = input.focus();
} else if let Some(btn) = cancel_ref.get() {
let _ = btn.focus();
}
});
let on_keydown = move |ev: leptos::ev::KeyboardEvent| {
use wasm_bindgen::JsCast;
if ev.key() == "Escape" && !busy.get_untracked() {
open.set(false);
return;
}
// Trap Tab inside the dialog — a modal must not leak focus to the page
// behind it. Computed over whatever is currently focusable (the phrase
// input, and the confirm button only once it is armed), never a fixed
// pair, so the trap stays correct as the dialog's contents change.
if ev.key() == "Tab" {
let Some(d) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let Ok(nodes) = d.query_selector_all(
".dialog button:not([disabled]), .dialog input:not([disabled]), \
.dialog select:not([disabled]), .dialog textarea:not([disabled]), \
.dialog a[href]",
) else {
return;
};
let len = nodes.length();
if len == 0 {
return;
}
let at = |i: u32| {
nodes
.item(i)
.and_then(|n| n.dyn_into::<web_sys::HtmlElement>().ok())
};
let (Some(first), Some(last)) = (at(0), at(len - 1)) else {
return;
};
let active = d.active_element();
let is = |el: &web_sys::HtmlElement| {
active.as_ref().map(|a| a.is_same_node(Some(el))) == Some(true)
};
if !ev.shift_key() && is(&last) {
ev.prevent_default();
let _ = first.focus();
} else if ev.shift_key() && is(&first) {
ev.prevent_default();
let _ = last.focus();
}
}
};
view! {
{move || open.get().then(|| {
let heading = title.clone();
let label = title.clone();
let consequence = consequence.clone();
let confirm_label = confirm_label.clone();
let children = children.clone();
view! {
<div class="dialog-scrim"
on:click=move |_| { if !busy.get_untracked() { open.set(false) } }
on:keydown=on_keydown>
<div class="dialog" role="alertdialog" aria-modal="true" aria-label=label
on:click=|ev| ev.stop_propagation()>
<h3 class="dialog-title">{heading}</h3>
<div class="dialog-body">{children()}</div>
<p class="dialog-consequence">{consequence}</p>
{confirm_phrase.map(|p| view! {
<label class="axis phrase-gate">
<span>
"Type "
<code>{move || p.get()}</code>
" to confirm"
</span>
// No Enter-to-submit: an irreversible action
// deserves a deliberate second gesture.
<input type="text" node_ref=phrase_ref
autocomplete="off" spellcheck="false"
aria-describedby="phrase-gate-state"
prop:value=move || typed.get()
on:input=move |ev| typed.set(event_target_value(&ev))/>
<span id="phrase-gate-state" class="count" role="status">
{move || if armed.get() { "name matches" } else { "waiting for the exact name" }}
</span>
</label>
})}
<div class="toolbar">
<button class="danger-big"
on:click=move |_| on_confirm.run(())
disabled=move || busy.get() || !armed.get()>
{move || if busy.get() { "Working…".to_string() } else { confirm_label.clone() }}
</button>
// Cancel is the safe default when nothing must be
// typed; with a phrase gate the input leads.
<button class="dialog-cancel" node_ref=cancel_ref
on:click=move |_| open.set(false) disabled=move || busy.get()>
"Cancel"
</button>
</div>
</div>
</div>
}
})}
}
}