yew-limput 0.4.0

A yew component that provides an html input with real-time value filtering
Documentation
use std::rc::Rc;

use tracing::error;
use web_sys::HtmlInputElement;
use web_sys::wasm_bindgen::JsCast;
use yew::prelude::*;

pub type LimitedInputFilter = dyn for<'a> Fn(&'a char) -> bool;

#[macro_export]
macro_rules! input_filter {
    ($filter:expr) => {
        Rc::new($filter) as Rc<$crate::LimitedInputFilter>
    };
}

#[derive(Properties)]
struct LimitedInputProps {
    input_type: &'static str,
    input_ref: NodeRef,
    class: AttrValue,
    id: AttrValue,
    name: AttrValue,
    autocomplete: bool,
    append_only: bool,
    filter: Rc<LimitedInputFilter>,
    max_len: Option<usize>,
    on_max_len: Callback<String>,
    on_code_change: Callback<String>,
}

impl PartialEq for LimitedInputProps {
    fn eq(&self, other: &Self) -> bool {
        self.input_type == other.input_type
            && self.class == other.class
            && Rc::ptr_eq(&self.filter, &other.filter)
            && self.max_len == other.max_len
    }
}

#[function_component]
fn LimitedInput(props: &LimitedInputProps) -> Html {
    let oninput = {
        let filter = props.filter.clone();
        let append_only = props.append_only;
        let max_len = props.max_len.unwrap_or(usize::MAX);
        let on_max_len = props.on_max_len.clone();
        let on_code_change = props.on_code_change.clone();

        move |event: InputEvent| {
            let Some(target) = event.target() else {
                error!("input event has no target");
                return;
            };

            let Ok(input) = target.dyn_into::<HtmlInputElement>() else {
                error!("input event target is not an input element");
                return;
            };

            let value = input.value();

            let filtered_value: String = value
                .chars()
                .filter(|c| (filter)(c))
                .take(max_len)
                .collect();

            input.set_value(&filtered_value);

            if append_only {
                let len = filtered_value.len() as u32;
                if let Err(err) = input.set_selection_range(len, len) {
                    error!("failed to set selection range: {:?}", err);
                }
            }

            on_code_change.emit(filtered_value.clone());

            if filtered_value.len() >= max_len {
                on_max_len.emit(filtered_value);
            }
        }
    };

    let onkeydown = if props.append_only {
        |event: KeyboardEvent| {
            if matches!(
                event.key().as_str(),
                "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
            ) {
                event.prevent_default();
            }
        }
    } else {
        |_event: KeyboardEvent| {}
    };

    let input_ref = &props.input_ref;
    let id = &props.id;
    let name = &props.name;
    let autocomplete = if props.autocomplete { "on" } else { "off" };

    html! {
        <input
            ref={input_ref}
            {id}
            {name}
            {autocomplete}
            type={props.input_type}
            class={&props.class}
            {oninput}
            {onkeydown}
        />
    }
}

#[derive(Properties)]
pub struct LimitedTextInputProps {
    pub input_ref: Option<NodeRef>,
    #[prop_or_default]
    pub class: AttrValue,
    #[prop_or_default]
    pub id: AttrValue,
    #[prop_or_default]
    pub name: AttrValue,
    #[prop_or(true)]
    pub autocomplete: bool,
    #[prop_or(false)]
    pub append_only: bool,
    pub filter: Rc<LimitedInputFilter>,
    pub max_len: Option<usize>,
    #[prop_or_else(|| Callback::noop())]
    pub on_max_len: Callback<String>,
    #[prop_or_else(|| Callback::noop())]
    pub on_code_change: Callback<String>,
}

impl PartialEq for LimitedTextInputProps {
    fn eq(&self, other: &Self) -> bool {
        self.class == other.class
            && Rc::ptr_eq(&self.filter, &other.filter)
            && self.max_len == other.max_len
    }
}

#[function_component]
pub fn LimitedTextInput(props: &LimitedTextInputProps) -> Html {
    let input_type = "text";
    let input_ref = props.input_ref.clone().unwrap_or_default();
    let class = &props.class;
    let id = &props.id;
    let name = &props.name;
    let autocomplete = props.autocomplete;
    let append_only = props.append_only;
    let filter = props.filter.clone();
    let max_len = props.max_len;
    let on_max_len = props.on_max_len.clone();
    let on_code_change = props.on_code_change.clone();

    html! {
        <LimitedInput
            {input_type}
            {input_ref}
            {class}
            {id}
            {name}
            {autocomplete}
            {append_only}
            {filter}
            {max_len}
            {on_max_len}
            {on_code_change}
        />
    }
}

#[derive(PartialEq, Properties)]
pub struct LimitedNumericInputProps {
    pub input_ref: Option<NodeRef>,
    #[prop_or_default]
    pub class: AttrValue,
    #[prop_or_default]
    pub id: AttrValue,
    #[prop_or_default]
    pub name: AttrValue,
    #[prop_or(true)]
    pub autocomplete: bool,
    #[prop_or(false)]
    pub append_only: bool,
    pub max_len: Option<usize>,
    #[prop_or_else(|| Callback::noop())]
    pub on_max_len: Callback<String>,
    #[prop_or_else(|| Callback::noop())]
    pub on_code_change: Callback<String>,
}

#[function_component]
pub fn LimitedNumericInput(props: &LimitedNumericInputProps) -> Html {
    let input_ref = props.input_ref.clone().unwrap_or_default();
    let class = &props.class;
    let id = &props.id;
    let name = &props.name;
    let autocomplete = props.autocomplete;
    let append_only = props.append_only;
    let filter = input_filter!(|c: &char| c.is_ascii_digit());
    let max_len = props.max_len;
    let on_max_len = props.on_max_len.clone();
    let on_code_change = props.on_code_change.clone();

    html! {
        <LimitedTextInput
            {input_ref}
            {class}
            {id}
            {name}
            {autocomplete}
            {append_only}
            {filter}
            {max_len}
            {on_max_len}
            {on_code_change}
            />
    }
}