Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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>
            }
        })}
    }
}