feat: Input adds rules prop

This commit is contained in:
luoxiao 2024-08-19 17:40:53 +08:00 committed by luoxiaozero
parent a45354c852
commit 615c33a5c8
7 changed files with 402 additions and 23 deletions

View file

@ -37,6 +37,7 @@ chrono = "0.4.38"
palette = "0.7.6" palette = "0.7.6"
num-traits = "0.2.19" num-traits = "0.2.19"
send_wrapper = "0.6" send_wrapper = "0.6"
slotmap = "1.0"
[features] [features]
csr = ["leptos/csr", "thaw_components/csr", "thaw_utils/csr"] csr = ["leptos/csr", "thaw_components/csr", "thaw_utils/csr"]

View file

@ -20,3 +20,30 @@ view! {
</Field> </Field>
} }
``` ```
### validate
```rust demo
view! {
<form>
<FieldContextProvider>
<Field label="Example field">
<Input />
</Field>
<button
type="submit"
on:click={
let field_context = FieldContextInjection::expect_context();
move |e| {
if !field_context.validate() {
e.prevent_default();
}
}
}
>
"Submit"
</button>
</FieldContextProvider>
</form>
}
```

View file

@ -26,3 +26,39 @@
padding-bottom: var(--spacingVerticalSNudge); padding-bottom: var(--spacingVerticalSNudge);
padding-top: 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);
}

View file

@ -1,4 +1,4 @@
use leptos::{context::Provider, prelude::*}; use leptos::{context::Provider, either::EitherOf3, prelude::*};
use thaw_components::OptionComp; use thaw_components::OptionComp;
use thaw_utils::{class_list, mount_style}; use thaw_utils::{class_list, mount_style};
use uuid::Uuid; use uuid::Uuid;
@ -6,13 +6,18 @@ use uuid::Uuid;
#[component] #[component]
pub fn Field( pub fn Field(
#[prop(optional, into)] class: MaybeProp<String>, #[prop(optional, into)] class: MaybeProp<String>,
#[prop(optional, into)] label: MaybeProp<String>, /// The label associated with the field.
#[prop(optional, into)]
label: MaybeProp<String>,
#[prop(optional, into)] name: MaybeProp<String>, #[prop(optional, into)] name: MaybeProp<String>,
#[prop(optional, into)] orientation: MaybeSignal<FieldOrientation>, /// The orientation of the label relative to the field component.
#[prop(optional, into)]
orientation: MaybeSignal<FieldOrientation>,
children: Children, children: Children,
) -> impl IntoView { ) -> impl IntoView {
mount_style("field", include_str!("./field.css")); mount_style("field", include_str!("./field.css"));
let id = StoredValue::new(Uuid::new_v4().to_string()); let id = StoredValue::new(Uuid::new_v4().to_string());
let validation_state = RwSignal::new(None::<FieldValidationState>);
view! { view! {
<div class=class_list![ <div class=class_list![
@ -22,7 +27,9 @@ pub fn Field(
}, },
class class
]> ]>
{move || { {
let label = label.clone();
move || {
view! { view! {
<OptionComp value=label.get() let:label> <OptionComp value=label.get() let:label>
<label class="thaw-field__label" for=id.get_value()> <label class="thaw-field__label" for=id.get_value()>
@ -30,8 +37,74 @@ pub fn Field(
</label> </label>
</OptionComp> </OptionComp>
} }
}
}
<Provider value=FieldInjection { id, name, label, validation_state }>{children()}</Provider>
{move || {
view! {
<OptionComp value=validation_state.get() let:validation_state>
{
match validation_state {
FieldValidationState::Error(message) => EitherOf3::A(view! {
<div class="thaw-field__validation-message">
<span class="thaw-field__validation-message-icon thaw-field__validation-message-icon--error">
<svg
fill="currentColor"
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 12 12">
<path
d="M6 11A5 5 0 1 0 6 1a5 5 0 0 0 0 10Zm-.75-2.75a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm.26-4.84a.5.5 0 0 1 .98 0l.01.09v2.59a.5.5 0 0 1-1 0V3.41Z"
fill="currentColor">
</path>
</svg>
</span>
{message}
</div>
}),
FieldValidationState::Success(message) => EitherOf3::B(view! {
<div class="thaw-field__validation-message">
<span class="thaw-field__validation-message-icon thaw-field__validation-message-icon--success">
<svg
fill="currentColor"
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 12 12">
<path
d="M1 6a5 5 0 1 1 10 0A5 5 0 0 1 1 6Zm7.35-.9a.5.5 0 1 0-.7-.7L5.5 6.54 4.35 5.4a.5.5 0 1 0-.7.7l1.5 1.5c.2.2.5.2.7 0l2.5-2.5Z"
fill="currentColor">
</path>
</svg>
</span>
{message}
</div>
}),
FieldValidationState::Warning(message) => EitherOf3::C(view! {
<div class="thaw-field__validation-message">
<span class="thaw-field__validation-message-icon thaw-field__validation-message-icon--warning">
<svg
fill="currentColor"
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 12 12">
<path
d="M5.21 1.46a.9.9 0 0 1 1.58 0l4.09 7.17a.92.92 0 0 1-.79 1.37H1.91a.92.92 0 0 1-.79-1.37l4.1-7.17ZM5.5 4.5v1a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-1 0ZM6 6.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"
fill="currentColor">
</path>
</svg>
</span>
{message}
</div>
})
}
}
</OptionComp>
}
}} }}
<Provider value=FieldInjection { id, name }>{children()}</Provider>
</div> </div>
} }
} }
@ -40,11 +113,69 @@ pub fn Field(
pub(crate) struct FieldInjection { pub(crate) struct FieldInjection {
id: StoredValue<String>, id: StoredValue<String>,
name: MaybeProp<String>, name: MaybeProp<String>,
label: MaybeProp<String>,
validation_state: RwSignal<Option<FieldValidationState>>,
} }
impl FieldInjection { impl FieldInjection {
pub fn expect_context() -> Self { pub fn use_context() -> Option<Self> {
expect_context() use_context()
}
pub fn id(&self) -> Option<String> {
if self.label.with(|l| l.is_some()) {
Some(self.id.get_value())
} else {
None
}
}
pub fn name(&self) -> Option<String> {
self.name.get()
}
pub fn use_id_and_name(
id: MaybeProp<String>,
name: MaybeProp<String>,
) -> (Signal<Option<String>>, Signal<Option<String>>) {
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<FieldValidationState>) {
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),
}

View file

@ -0,0 +1,61 @@
use leptos::{context::Provider, prelude::*};
use slotmap::{DefaultKey, SlotMap};
#[component]
pub fn FieldContextProvider(children: Children) -> impl IntoView {
view! {
<Provider value=FieldContextInjection::new()>{children()}</Provider>
}
}
#[derive(Clone)]
pub struct FieldContextInjection(
StoredValue<SlotMap<DefaultKey, (Signal<Option<String>>, Callback<(), bool>)>>,
);
impl FieldContextInjection {
fn new() -> Self {
Self(StoredValue::new(SlotMap::new()))
}
pub fn expect_context() -> Self {
expect_context()
}
pub fn use_context() -> Option<Self> {
use_context()
}
pub(crate) fn register_field(&self, name: Signal<Option<String>>, 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
})
}
}

View file

@ -1,3 +1,5 @@
mod field; mod field;
mod field_context_provider;
pub use field::*; pub use field::*;
pub use field_context_provider::*;

View file

@ -1,3 +1,4 @@
use crate::{FieldContextInjection, FieldInjection, FieldValidationState};
use leptos::{ev, html, prelude::*}; use leptos::{ev, html, prelude::*};
use thaw_utils::{ use thaw_utils::{
class_list, mount_style, ArcOneCallback, BoxOneCallback, ComponentRef, Model, OptionalProp, class_list, mount_style, ArcOneCallback, BoxOneCallback, ComponentRef, Model, OptionalProp,
@ -20,6 +21,12 @@ pub struct InputSuffix {
#[component] #[component]
pub fn Input( pub fn Input(
#[prop(optional, into)] class: MaybeProp<String>, #[prop(optional, into)] class: MaybeProp<String>,
#[prop(optional, into)] id: MaybeProp<String>,
/// 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<String>,
#[prop(optional, into)] rules: Vec<InputRule>,
/// Set the input value. /// Set the input value.
#[prop(optional, into)] #[prop(optional, into)]
value: Model<String>, value: Model<String>,
@ -53,12 +60,43 @@ pub fn Input(
// #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, // #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
) -> impl IntoView { ) -> impl IntoView {
mount_style("input", include_str!("./input.css")); 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 parser_none = parser.is_none();
let on_input = { let on_input = {
let allow_value = allow_value.clone(); let allow_value = allow_value.clone();
move |e| { move |e| {
if !parser_none { if !parser_none {
validate.run(Some(InputRuleTrigger::Input));
return; return;
} }
let input_value = event_target_value(&e); let input_value = event_target_value(&e);
@ -69,10 +107,12 @@ pub fn Input(
} }
} }
value.set(input_value); value.set(input_value);
validate.run(Some(InputRuleTrigger::Input));
} }
}; };
let on_change = move |e| { let on_change = move |e| {
let Some(parser) = parser.as_ref() else { let Some(parser) = parser.as_ref() else {
validate.run(Some(InputRuleTrigger::Change));
return; return;
}; };
let Some(parsed_input_value) = parser(event_target_value(&e)) else { let Some(parsed_input_value) = parser(event_target_value(&e)) else {
@ -86,6 +126,7 @@ pub fn Input(
} }
} }
value.set(parsed_input_value); value.set(parsed_input_value);
validate.run(Some(InputRuleTrigger::Change));
}; };
let is_focus = RwSignal::new(false); let is_focus = RwSignal::new(false);
let on_internal_focus = move |ev| { let on_internal_focus = move |ev| {
@ -93,12 +134,14 @@ pub fn Input(
if let Some(on_focus) = on_focus.as_ref() { if let Some(on_focus) = on_focus.as_ref() {
on_focus(ev); on_focus(ev);
} }
validate.run(Some(InputRuleTrigger::Focus));
}; };
let on_internal_blur = move |ev| { let on_internal_blur = move |ev| {
is_focus.set(false); is_focus.set(false);
if let Some(on_blur) = on_blur.as_ref() { if let Some(on_blur) = on_blur.as_ref() {
on_blur(ev); on_blur(ev);
} }
validate.run(Some(InputRuleTrigger::Blur));
}; };
let input_ref = NodeRef::<html::Input>::new(); let input_ref = NodeRef::<html::Input>::new();
@ -127,19 +170,6 @@ pub fn Input(
input_value = None; 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 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_); let suffix_if_ = input_suffix.as_ref().map_or(false, |suffix| suffix.if_);
@ -162,7 +192,9 @@ pub fn Input(
}} }}
<input <input
id=id
type=move || input_type.get().as_str() type=move || input_type.get().as_str()
name=name
value=input_value value=input_value
prop:value=move || { prop:value=move || {
let value = value.get(); let value = value.get();
@ -247,3 +279,85 @@ impl InputRef {
} }
} }
} }
#[derive(Debug, Default, PartialEq, Clone, Copy)]
pub enum InputRuleTrigger {
#[default]
Blur,
Focus,
Input,
Change,
}
enum InputRuleValidator {
Required(MaybeSignal<bool>),
RequiredMessage(MaybeSignal<bool>, MaybeSignal<String>),
Validator(Callback<String, Result<(), FieldValidationState>>),
}
pub struct InputRule {
validator: InputRuleValidator,
trigger: InputRuleTrigger,
}
impl InputRule {
pub fn required(required: MaybeSignal<bool>) -> Self {
Self {
trigger: Default::default(),
validator: InputRuleValidator::Required(required),
}
}
pub fn required_with_message(
required: MaybeSignal<bool>,
message: MaybeSignal<String>,
) -> 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<InputRuleTrigger>,
value: Model<String>,
) -> 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()),
})
}
}