refactor: move all lists into their own module

This commit is contained in:
alphafetus committed 2026-01-04 23:18:02 -05:00
1 parent d9951000e2
commit f08fee1246
9 files changed
+22 -10

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
pub mod radio;
pub mod select;
pub use radio::*;
pub use select::*;
+158
View File
@@ -0,0 +1,158 @@
use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
use crate::components::{use_validation_context, InputState, ValidationState};
#[component]
pub fn Radio(
#[prop()] checked: Signal<bool>,
#[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: Signal<String>,
#[prop(into)] name: Signal<String>,
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView {
let id = create_unique_id(&name.get_untracked(), &value.get_untracked());
view! {
<label for=id class="flex items-center">
<input
// Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked)
class="
relative peer shrink-0
hidden"
type="radio"
id=id.clone()
name=name
on:change=move |_| on_change.run(())
prop:checked=checked
disabled=disabled
/>
// This div displays the box for the checkbox, and applies some styling to the child
// span element.
<div class="relative inline-block h-5 w-5 m-1 mr-2 rounded-full border border-gray-300 bg-white
transition-bg duration-200 peer-checked:bg-red-700 peer-checked:border-red-700
[&>span]:opacity-0 peer-checked:[&>span]:opacity-100
peer-disabled:bg-gray-600/33 peer-disabled:border-gray-600
peer-disabled:peer-checked:bg-gray-600">
// This span is the check icon. It is centered within the div and has a border on the bottom
// and right sides, which is then rotated to appear like a checkmark.
<span class="absolute left-1/2 top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2
rotate-45 transform border-3 border-3 rounded-full bg-white
border-white transition-opacity duration-150"></span>
</div>
<span>{value.clone()}</span>
</label>
}
}
fn validate(value: String, required: bool) -> ValidationState {
if required && value.is_empty() {
ValidationState::Invalid("This field is required".into())
} else {
ValidationState::Valid
}
}
#[component]
pub fn RadioList(
#[prop(into)] data_member: Signal<String>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] items: Vec<String>,
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>,
) -> impl IntoView {
//#region Value Logic
// Store a single signal that is the currently selected value. We can only have one selected
// value, so there's no list needed. The default selected value is whatever the map has selected
// already, or nothing.
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
let selected_value = RwSignal::new(default_value);
// When the selected value changes, update the data map at the correct location
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
});
//#endregion
//#region Validation Logic
let validation_context = use_validation_context()
.expect("Could not find FormValidationRegistry context");
let error = RwSignal::new(validate(selected_value.get_untracked(), required.get_untracked()));
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || {
dirty.get() && matches!(error.get(), ValidationState::Invalid(_))
});
let input_state = InputState::new(
data_member.get_untracked(),
error.clone(),
dirty.clone()
);
validation_context.validators.update(|list| list.push(RwSignal::new(input_state)));
//#endregion
//#region Render Logic
view! {
<div class="flex flex-col flex-1">
<div class="m-1.5 mt-0 mb-0">
<span class="font-bold">{label}</span>
{items
.into_iter()
.map(|item| {
let item = RwSignal::new(item);
let checked_signal = Signal::derive(move || {
selected_value.get() == item.get()
});
let on_change = move || {
// on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one.
selected_value.set(item.get());
error.set(validate(selected_value.get_untracked(), required.get_untracked()));
dirty.set(true);
};
view! {
<Radio
checked=checked_signal
on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item
name=data_member
disabled=disabled
/>
}
})
.collect::<Vec<_>>()}
</div>
<Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
{move || {
match error.get() {
ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string()
}
}}
</div>
</Show>
</div>
}
//#endregion
}
+130
View File
@@ -0,0 +1,130 @@
use crate::common::ValueType;
use leptos::ev::{Event, Targeted};
use leptos::prelude::*;
use leptos::web_sys::HtmlSelectElement;
use std::collections::HashMap;
use leptos::logging::{log, warn};
use crate::components::{use_validation_context, InputState, ValidationState};
fn validate(required: bool, value: String, default_value: String) -> ValidationState {
if required && (value.is_empty() || value == default_value) {
ValidationState::Invalid("This input is required.".to_string())
} else {
ValidationState::Valid
}
}
/// A custom-styled select input.
///
/// Takes a `Vec<String>` as the set of inputs. Only one value may be selected, which is indicated
/// by the `value` prop of the component.
#[component]
pub fn Select(
#[prop()] value_list: Vec<String>,
#[prop(optional, into)] default_value: Signal<String>,
#[prop(optional)] value: RwSignal<String>,
#[prop(into)] data_member: Signal<String>,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional, into)] label: String,
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] required: Signal<bool>
) -> impl IntoView {
//#region Value Logic
let default_raw_value = if default_value.get_untracked().is_empty() {
data_map.get_untracked()
.get(&data_member.get_untracked())
.cloned()
.unwrap_or_default()
.as_string().unwrap_or_default().unwrap_or_default()
} else {
let default = default_value.get_untracked();
if !value_list.contains(&default) {
warn!("The default value {} is not in the list of options. This may cause validation issues.", default);
}
default
};
let raw_value = RwSignal::new(default_raw_value);
Effect::new(move || {
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(raw_value.get())));
});
});
//#endregion
//#region Validation Logic
let error = RwSignal::new(
validate(required.get_untracked(), raw_value.get_untracked(), default_value.get_untracked())
);
warn!("Current error state: {:?}", error.get_untracked());
let dirty = RwSignal::new(false);
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
let input_state = InputState::new(data_member.get(), error.clone(), dirty.clone());
let validation_context = use_validation_context()
.expect("Could not find FormValidationRegistry context");
validation_context.validators.update(|list| list.push(RwSignal::new(input_state)));
//#endregion
//#region Event Logic
let on_change = move |e: Targeted<Event, HtmlSelectElement>| {
let value = e.target().value();
log!("Found value {}", value);
raw_value.set(value.clone());
error.set(validate(required.get(), raw_value.get(), default_value.get()));
dirty.set(true);
data_map.update(|map| {
if let Some(val) = map.get_mut(&data_member.get()) {
*val = ValueType::String(Some(value.clone()));
} else {
map.insert(data_member.get(), ValueType::String(Some(value)));
}
});
};
//#endregion
view! {
<div class="flex flex-col flex-1">
<div class="flex flex-1">
<label class="flex flex-col flex-1">
<span class="ml-1.5 mb-0 font-bold">{label}</span>
<select
class="relative flex-1 border-2 m-1.5 mt-0 p-1.5 rounded-md
transition-border duration-150
border-red-700 bg-transparent
disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
on:change:target=on_change
disabled = disabled >
// This closure handles the display of the options.
{move || {
value_list.iter().map(
move |value| {
let display = value.to_owned();
view! { <option value={display}>{display.clone()}</option> }
}
).collect::<Vec<_>>()
}}
</select>
</label>
</div>
<Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
{move || {
match error.get() {
ValidationState::Invalid(msg) => msg,
_ => "There is no error - should not see this message.".to_string()
}
}}
</div>
</Show>
</div>
}
}