leptodon 0.1.0

your Leptos UI toolkit for data science
Documentation
// Leptodon
//
// Copyright (C) 2025-2026 Open Analytics NV
//
// ===========================================================================
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the Apache License as published by The Apache Software
// Foundation, either version 2 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the Apache License for more details.
//
// You should have received a copy of the Apache License along with this program.
// If not, see <http://www.apache.org/licenses/>
use leptos::prelude::AddAnyAttr;
use std::fmt::Display;
use std::ops::Add;
use std::ops::Sub;
use std::str::FromStr;

use crate::class_list;
use crate::icon;
use crate::input::InputMode;
use crate::input::InputType;
use crate::input_group::GroupItemContextProvider;
use leptos::logging::debug_log;
use leptos::prelude::ClassAttribute;
use leptos::prelude::Effect;
use leptos::prelude::ElementChild;
use leptos::prelude::Get;
use leptos::prelude::GlobalAttributes;
use leptos::prelude::MaybeProp;
use leptos::prelude::RwSignal;
use leptos::prelude::Set;
use leptos::prelude::Signal;
use leptos::{IntoView, component, view};
use num_traits::Bounded;
use num_traits::ConstOne;
use num_traits::SaturatingAdd;
use num_traits::SaturatingSub;

use crate::{button::Button, input::TextInput};

fn clamp<T>(some: T, min: T, max: T) -> T
where
    T: PartialOrd,
{
    if some < min {
        min
    } else if some > max {
        max
    } else {
        some
    }
}

#[component]
pub fn ControlledNumberInput<T>(
    /// Id
    #[prop(optional, into)]
    id: MaybeProp<String>,
    /// Name of the input.
    #[prop(optional, into)]
    name: MaybeProp<String>,
    /// Extra classes to size the component
    #[prop(optional, into)]
    class: MaybeProp<String>,
    /// The minimum number that the input value can take.
    #[prop(default = T::min_value().into(), into)]
    min: Signal<T>,
    /// The maximum number that the input value can take.
    #[prop(default = T::max_value().into(), into)]
    max: Signal<T>,
    /// Placeholder of input number.
    #[prop(optional, into)]
    placeholder: MaybeProp<String>,
    /// Step size for the increment and decrement, defaults to the multiplicative identity of T (e.g. 1 for i32).
    #[prop(default = T::one())]
    step: T,
) -> impl IntoView
where
    T: Send + Sync,
    T: ConstOne
        + Add<Output = T>
        + Sub<Output = T>
        + SaturatingAdd<Output = T>
        + SaturatingSub<Output = T>
        + PartialOrd
        + Bounded,
    T: Default + Clone + Copy + FromStr + ToString + Display + 'static,
{
    // let value: RwSignal<Option<i32>> = RwSignal::new(None);
    let value_binder: RwSignal<String> = RwSignal::new("".to_string());
    Effect::watch(
        move || value_binder.get(),
        move |new_value, prev_value, _| {
            if Some(new_value) == prev_value {
                return;
            }
            let Ok(new_value) = new_value.parse::<T>() else {
                let text = String::new();
                if new_value.is_empty() {
                    // Loop breaker
                    return;
                }
                if prev_value.unwrap_or(&text).parse::<T>().is_err() {
                    debug_log!(
                        "User inputted a non-number {new_value:?} after a non-number, resetting to empty"
                    );
                    value_binder.set(text);
                    return;
                }
                debug_log!(
                    "User inputted a non-number {new_value:?} resetting their input to {prev_value:?}"
                );

                value_binder.set(prev_value.unwrap_or(&text).to_string());
                return;
            };
            if new_value != clamp::<T>(new_value, min.get(), max.get()) {
                debug_log!(
                    "User inputted a number {new_value} outside of ({}, {}) ",
                    min.get(),
                    max.get()
                );
                let text = String::new();
                value_binder.set(prev_value.unwrap_or(&text).to_string());
            };
        },
        false,
    );
    let inc_step = step;
    let inc_handler = move |_| {
        let unparsed = value_binder.get();
        let old_value = unparsed.parse::<T>();
        if let Ok(old_value) = old_value {
            value_binder.set(format!(
                "{}",
                clamp(old_value.saturating_add(&inc_step), min.get(), max.get())
            ));
            debug_log!("i++");
        } else if unparsed.is_empty() {
            value_binder.set("1".to_string());
        }
    };
    let dec_handler = move |_| {
        let unparsed = value_binder.get();
        let old_value = unparsed.parse::<T>();
        if let Ok(old_value) = old_value {
            value_binder.set(format!(
                "{}",
                clamp(old_value.saturating_sub(&step), min.get(), max.get())
            ));
            debug_log!("i--");
        } else if unparsed.is_empty() {
            value_binder.set("1".to_string());
        }
    };
    view! {
        <div id=id.get() class=class_list!(class, "relative flex items-center mb-2")>
            <GroupItemContextProvider class="rounded-none rounded-l-lg">
                <TextInput name placeholder input_type=InputType::Text input_mode=InputMode::Numeric value=value_binder />
            </GroupItemContextProvider>
            <GroupItemContextProvider class="rounded-none border-x-0 !mr-0">
                <Button icon=icon::DecrementIcon() on_click=dec_handler
                    attr:data-testid="decrement" />
            </GroupItemContextProvider>
            <GroupItemContextProvider class="rounded-none rounded-r-lg">
                <Button icon=icon::IncrementIcon() on_click=inc_handler
                    attr:data-testid="increment"/>
            </GroupItemContextProvider>
            // Block context leakage
            <GroupItemContextProvider class="">
                <div/>
            </GroupItemContextProvider>
        </div>
    }
}