mirror of
https://github.com/adoyle0/thaw.git
synced 2025-01-22 22:09:22 -05:00
feat: Input adds rules prop
This commit is contained in:
parent
a45354c852
commit
615c33a5c8
7 changed files with 402 additions and 23 deletions
|
@ -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"]
|
||||
|
|
|
@ -20,3 +20,30 @@ view! {
|
|||
</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>
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<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)] orientation: MaybeSignal<FieldOrientation>,
|
||||
/// The orientation of the label relative to the field component.
|
||||
#[prop(optional, into)]
|
||||
orientation: MaybeSignal<FieldOrientation>,
|
||||
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::<FieldValidationState>);
|
||||
|
||||
view! {
|
||||
<div class=class_list![
|
||||
|
@ -22,16 +27,84 @@ pub fn Field(
|
|||
},
|
||||
class
|
||||
]>
|
||||
{
|
||||
let label = label.clone();
|
||||
move || {
|
||||
view! {
|
||||
<OptionComp value=label.get() let:label>
|
||||
<label class="thaw-field__label" for=id.get_value()>
|
||||
{label}
|
||||
</label>
|
||||
</OptionComp>
|
||||
}
|
||||
}
|
||||
}
|
||||
<Provider value=FieldInjection { id, name, label, validation_state }>{children()}</Provider>
|
||||
{move || {
|
||||
view! {
|
||||
<OptionComp value=label.get() let:label>
|
||||
<label class="thaw-field__label" for=id.get_value()>
|
||||
{label}
|
||||
</label>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
@ -40,11 +113,69 @@ pub fn Field(
|
|||
pub(crate) struct FieldInjection {
|
||||
id: StoredValue<String>,
|
||||
name: MaybeProp<String>,
|
||||
label: MaybeProp<String>,
|
||||
validation_state: RwSignal<Option<FieldValidationState>>,
|
||||
}
|
||||
|
||||
impl FieldInjection {
|
||||
pub fn expect_context() -> Self {
|
||||
expect_context()
|
||||
pub fn use_context() -> Option<Self> {
|
||||
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),
|
||||
}
|
||||
|
|
61
thaw/src/field/field_context_provider.rs
Normal file
61
thaw/src/field/field_context_provider.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
mod field;
|
||||
mod field_context_provider;
|
||||
|
||||
pub use field::*;
|
||||
pub use field_context_provider::*;
|
||||
|
|
|
@ -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<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.
|
||||
#[prop(optional, into)]
|
||||
value: Model<String>,
|
||||
|
@ -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::<html::Input>::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(
|
|||
}}
|
||||
|
||||
<input
|
||||
id=id
|
||||
type=move || input_type.get().as_str()
|
||||
name=name
|
||||
value=input_value
|
||||
prop:value=move || {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue