From 615c33a5c86270e2603166b60652abd4a374c178 Mon Sep 17 00:00:00 2001 From: luoxiao Date: Mon, 19 Aug 2024 17:40:53 +0800 Subject: [PATCH] feat: Input adds rules prop --- thaw/Cargo.toml | 1 + thaw/src/field/docs/mod.md | 27 ++++ thaw/src/field/field.css | 36 ++++++ thaw/src/field/field.rs | 158 +++++++++++++++++++++-- thaw/src/field/field_context_provider.rs | 61 +++++++++ thaw/src/field/mod.rs | 2 + thaw/src/input/mod.rs | 140 ++++++++++++++++++-- 7 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 thaw/src/field/field_context_provider.rs diff --git a/thaw/Cargo.toml b/thaw/Cargo.toml index 8ecfb80..cf4cba3 100644 --- a/thaw/Cargo.toml +++ b/thaw/Cargo.toml @@ -37,6 +37,7 @@ chrono = "0.4.38" palette = "0.7.6" num-traits = "0.2.19" send_wrapper = "0.6" +slotmap = "1.0" [features] csr = ["leptos/csr", "thaw_components/csr", "thaw_utils/csr"] diff --git a/thaw/src/field/docs/mod.md b/thaw/src/field/docs/mod.md index e1febfc..64e3777 100644 --- a/thaw/src/field/docs/mod.md +++ b/thaw/src/field/docs/mod.md @@ -20,3 +20,30 @@ view! { } ``` + +### validate + +```rust demo +view! { +
+ + + + + + +
+} +``` diff --git a/thaw/src/field/field.css b/thaw/src/field/field.css index bdad7f6..c455dbd 100644 --- a/thaw/src/field/field.css +++ b/thaw/src/field/field.css @@ -26,3 +26,39 @@ padding-bottom: var(--spacingVerticalSNudge); padding-top: var(--spacingVerticalSNudge); } + +.thaw-field__validation-message { + padding-left: calc(12px + var(--spacingHorizontalXS)); + margin-top: var(--spacingVerticalXXS); + color: var(--colorNeutralForeground3); + font-family: var(--fontFamilyBase); + font-size: var(--fontSizeBase200); + font-weight: var(--fontWeightRegular); + line-height: var(--lineHeightBase200); +} + +.thaw-field__validation-message-icon { + display: inline-block; + font-size: 12px; + margin-left: calc(-12px - var(--spacingHorizontalXS)); + margin-right: var(--spacingHorizontalXS); + line-height: 0; + vertical-align: -1px; +} + +.thaw-field__validation-message-icon > svg { + display: inline; + line-height: 0; +} + +.thaw-field__validation-message-icon--success { + color: var(--colorPaletteGreenForeground1); +} + +.thaw-field__validation-message-icon--error { + color: var(--colorPaletteRedForeground1); +} + +.thaw-field__validation-message-icon--warning { + color: var(--colorPaletteDarkOrangeForeground1); +} diff --git a/thaw/src/field/field.rs b/thaw/src/field/field.rs index 5604aae..329c073 100644 --- a/thaw/src/field/field.rs +++ b/thaw/src/field/field.rs @@ -1,4 +1,4 @@ -use leptos::{context::Provider, prelude::*}; +use leptos::{context::Provider, either::EitherOf3, prelude::*}; use thaw_components::OptionComp; use thaw_utils::{class_list, mount_style}; use uuid::Uuid; @@ -6,13 +6,18 @@ use uuid::Uuid; #[component] pub fn Field( #[prop(optional, into)] class: MaybeProp, - #[prop(optional, into)] label: MaybeProp, + /// The label associated with the field. + #[prop(optional, into)] + label: MaybeProp, #[prop(optional, into)] name: MaybeProp, - #[prop(optional, into)] orientation: MaybeSignal, + /// The orientation of the label relative to the field component. + #[prop(optional, into)] + orientation: MaybeSignal, children: Children, ) -> impl IntoView { mount_style("field", include_str!("./field.css")); let id = StoredValue::new(Uuid::new_v4().to_string()); + let validation_state = RwSignal::new(None::); view! {
+ { + let label = label.clone(); + move || { + view! { + + + + } + } + } + {children()} {move || { view! { - - + + { + match validation_state { + FieldValidationState::Error(message) => EitherOf3::A(view! { +
+ + + + {message} +
+ }), + FieldValidationState::Success(message) => EitherOf3::B(view! { +
+ + + + {message} +
+ }), + FieldValidationState::Warning(message) => EitherOf3::C(view! { +
+ + + + {message} +
+ }) + } + } +
} }} - {children()}
} } @@ -40,11 +113,69 @@ pub fn Field( pub(crate) struct FieldInjection { id: StoredValue, name: MaybeProp, + label: MaybeProp, + validation_state: RwSignal>, } impl FieldInjection { - pub fn expect_context() -> Self { - expect_context() + pub fn use_context() -> Option { + use_context() + } + + pub fn id(&self) -> Option { + if self.label.with(|l| l.is_some()) { + Some(self.id.get_value()) + } else { + None + } + } + + pub fn name(&self) -> Option { + self.name.get() + } + + pub fn use_id_and_name( + id: MaybeProp, + name: MaybeProp, + ) -> (Signal>, Signal>) { + let field_injection = Self::use_context(); + let id = Signal::derive(move || { + if let Some(id) = id.get() { + return Some(id); + } + + let Some(field_injection) = field_injection.as_ref() else { + return None; + }; + + field_injection.id() + }); + + let field_injection = Self::use_context(); + let name = Signal::derive(move || { + if let Some(name) = name.get() { + return Some(name); + } + + let Some(field_injection) = field_injection.as_ref() else { + return None; + }; + + field_injection.name() + }); + + (id, name) + } + + pub fn update_validation_state(&self, state: Option) { + self.validation_state.try_maybe_update(|validation_state| { + if validation_state == &state { + (false, ()) + } else { + *validation_state = state; + (true, ()) + } + }); } } @@ -63,3 +194,10 @@ impl FieldOrientation { } } } + +#[derive(Debug, Clone, PartialEq)] +pub enum FieldValidationState { + Error(String), + Success(String), + Warning(String), +} diff --git a/thaw/src/field/field_context_provider.rs b/thaw/src/field/field_context_provider.rs new file mode 100644 index 0000000..390fa64 --- /dev/null +++ b/thaw/src/field/field_context_provider.rs @@ -0,0 +1,61 @@ +use leptos::{context::Provider, prelude::*}; +use slotmap::{DefaultKey, SlotMap}; + +#[component] +pub fn FieldContextProvider(children: Children) -> impl IntoView { + view! { + {children()} + } +} + +#[derive(Clone)] +pub struct FieldContextInjection( + StoredValue>, Callback<(), bool>)>>, +); + +impl FieldContextInjection { + fn new() -> Self { + Self(StoredValue::new(SlotMap::new())) + } + + pub fn expect_context() -> Self { + expect_context() + } + + pub fn use_context() -> Option { + use_context() + } + + pub(crate) fn register_field(&self, name: Signal>, validate: impl Fn() -> bool + Send + Sync + 'static) { + let mut key = None; + self.0 + .update_value(|map| key = Some(map.insert((name, Callback::from(move |_| validate()))))); + + let map = self.0.clone(); + Owner::on_cleanup(move || { + map.update_value(|map| map.remove(key.unwrap())); + }); + } + + pub fn validate(&self) -> bool { + self.0.with_value(|map| { + for (_, (_, validate)) in map.iter() { + if !validate.run(()) { + return false; + } + } + true + }) + } + + pub fn validate_field(&self, name: String) -> bool { + self.0.with_value(|map| { + for (_, (n, validate)) in map.iter() { + if n.get_untracked().as_ref() == Some(&name) && !validate.run(()) { + return false; + } + } + true + }) + } +} diff --git a/thaw/src/field/mod.rs b/thaw/src/field/mod.rs index 2f98b74..10808a2 100644 --- a/thaw/src/field/mod.rs +++ b/thaw/src/field/mod.rs @@ -1,3 +1,5 @@ mod field; +mod field_context_provider; pub use field::*; +pub use field_context_provider::*; diff --git a/thaw/src/input/mod.rs b/thaw/src/input/mod.rs index 0689107..1d61dae 100644 --- a/thaw/src/input/mod.rs +++ b/thaw/src/input/mod.rs @@ -1,3 +1,4 @@ +use crate::{FieldContextInjection, FieldInjection, FieldValidationState}; use leptos::{ev, html, prelude::*}; use thaw_utils::{ class_list, mount_style, ArcOneCallback, BoxOneCallback, ComponentRef, Model, OptionalProp, @@ -20,6 +21,12 @@ pub struct InputSuffix { #[component] pub fn Input( #[prop(optional, into)] class: MaybeProp, + #[prop(optional, into)] id: MaybeProp, + /// A string specifying a name for the input control. + /// This name is submitted along with the control's value when the form data is submitted. + #[prop(optional, into)] + name: MaybeProp, + #[prop(optional, into)] rules: Vec, /// Set the input value. #[prop(optional, into)] value: Model, @@ -53,12 +60,43 @@ pub fn Input( // #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, ) -> impl IntoView { mount_style("input", include_str!("./input.css")); + let (id, name) = FieldInjection::use_id_and_name(id, name); + let field_injection = FieldInjection::use_context(); + let rules = StoredValue::new(rules); + let validate = Callback::new(move |trigger| { + let state = rules.with_value(move |rules| { + if rules.is_empty() { + return None; + } + + let mut rules_iter = rules.iter(); + loop { + let Some(rule) = rules_iter.next() else { + return None; + }; + + if let Err(state) = rule.call_validator(trigger, value) { + return Some(state); + } + } + }); + + let rt = state.is_some(); + if let Some(field_injection) = field_injection.as_ref() { + field_injection.update_validation_state(state); + }; + rt + }); + if let Some(field_context) = FieldContextInjection::use_context() { + field_context.register_field(name, move || validate.run(None)); + } let parser_none = parser.is_none(); let on_input = { let allow_value = allow_value.clone(); move |e| { if !parser_none { + validate.run(Some(InputRuleTrigger::Input)); return; } let input_value = event_target_value(&e); @@ -69,10 +107,12 @@ pub fn Input( } } value.set(input_value); + validate.run(Some(InputRuleTrigger::Input)); } }; let on_change = move |e| { let Some(parser) = parser.as_ref() else { + validate.run(Some(InputRuleTrigger::Change)); return; }; let Some(parsed_input_value) = parser(event_target_value(&e)) else { @@ -86,6 +126,7 @@ pub fn Input( } } value.set(parsed_input_value); + validate.run(Some(InputRuleTrigger::Change)); }; let is_focus = RwSignal::new(false); let on_internal_focus = move |ev| { @@ -93,12 +134,14 @@ pub fn Input( if let Some(on_focus) = on_focus.as_ref() { on_focus(ev); } + validate.run(Some(InputRuleTrigger::Focus)); }; let on_internal_blur = move |ev| { is_focus.set(false); if let Some(on_blur) = on_blur.as_ref() { on_blur(ev); } + validate.run(Some(InputRuleTrigger::Blur)); }; let input_ref = NodeRef::::new(); @@ -127,19 +170,6 @@ pub fn Input( input_value = None; } - // #[cfg(debug_assertions)] - // { - // const INNER_ATTRS: [&str; 4] = ["type", "class", "disabled", "placeholder"]; - // attrs.iter().for_each(|attr| { - // if INNER_ATTRS.contains(&attr.0) { - // logging::warn!( - // "Thaw: The '{}' attribute already exists on elements inside the Input component, which may cause conflicts.", - // attr.0 - // ); - // } - // }); - // } - let prefix_if_ = input_prefix.as_ref().map_or(false, |prefix| prefix.if_); let suffix_if_ = input_suffix.as_ref().map_or(false, |suffix| suffix.if_); @@ -162,7 +192,9 @@ pub fn Input( }} ), + RequiredMessage(MaybeSignal, MaybeSignal), + Validator(Callback>), +} + +pub struct InputRule { + validator: InputRuleValidator, + trigger: InputRuleTrigger, +} + +impl InputRule { + pub fn required(required: MaybeSignal) -> Self { + Self { + trigger: Default::default(), + validator: InputRuleValidator::Required(required), + } + } + + pub fn required_with_message( + required: MaybeSignal, + message: MaybeSignal, + ) -> Self { + Self { + trigger: Default::default(), + validator: InputRuleValidator::RequiredMessage(required, message), + } + } + + pub fn validator(f: impl Fn(&String) -> Result<(), FieldValidationState> + Send + Sync + 'static) -> Self { + Self { + trigger: Default::default(), + validator: InputRuleValidator::Validator(Callback::from(move |v| f(&v))), + } + } + + pub fn with_trigger(mut self, trigger: InputRuleTrigger) -> Self { + self.trigger = trigger; + + self + } + + fn call_validator( + &self, + trigger: Option, + value: Model, + ) -> Result<(), FieldValidationState> { + if let Some(trigger) = trigger { + if self.trigger != trigger { + return Ok(()); + } + } + + value.with_untracked(|value| match &self.validator { + InputRuleValidator::Required(required) => { + if required.get_untracked() && value.is_empty() { + Err(FieldValidationState::Error(String::from(""))) + } else { + Ok(()) + } + } + InputRuleValidator::RequiredMessage(required, message) => { + if required.get_untracked() && value.is_empty() { + Err(FieldValidationState::Error(message.get_untracked())) + } else { + Ok(()) + } + } + InputRuleValidator::Validator(f) => f.run(value.clone()), + }) + } +}