feat: implement other field for radio buttons

This commit is contained in:
alphafetus committed 2026-01-14 07:47:08 -05:00
1 parent 1d58e99878
commit b19b696490
4 files changed
+149 -38

No files matched your search

+145 -14
View File
@@ -2,6 +2,7 @@ use crate::common::ValueType;
use crate::components::utils::create_unique_id; use crate::components::utils::create_unique_id;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use leptos::html::Input;
use crate::components::{use_validation_context, InputState, ValidationState}; use crate::components::{use_validation_context, InputState, ValidationState};
#[component] #[component]
@@ -20,9 +21,7 @@ pub fn Radio(
// Using the "peer" class links this input with the div "peer" at the same level. // 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 // By using the "peer-checked" selector this div can toggle states based on this
// input's state (checked/unchecked) // input's state (checked/unchecked)
class=" class="relative peer shrink-0 hidden"
relative peer shrink-0
hidden"
type="radio" type="radio"
id=id.clone() id=id.clone()
name=name name=name
@@ -48,6 +47,94 @@ pub fn Radio(
} }
} }
/// # Ratio Other Component
/// This is a simple "other" field that contains a text input component. It can be selected using the
/// same logic as the [`Radio`] component, and also has a secondary `RwSignal` that contains the
/// current value of the embedded text input.
///
/// Example usage: <still working on this>
#[component]
fn RadioOther(
#[prop(into)] name: Signal<String>,
#[prop(into)] checked: Signal<bool>,
#[prop(into)] on_change: Callback<()>,
#[prop(into)] disabled: Signal<bool>,
#[prop(optional, into)] value: RwSignal<String>,
#[prop(into)] dirty: RwSignal<bool>,
#[prop(into)] placeholder: Signal<String>,
) -> impl IntoView {
let id = create_unique_id(&name.get_untracked(), &"other".to_string());
let input_ref = NodeRef::<Input>::new();
let input_disabled = Signal::derive(move || {
!checked.get() || disabled.get()
});
Effect::new(move || {
checked.get()
.then(|| {
if let Some(node) = input_ref.get() {
node.focus().expect("Couldn't focus input element");
}
});
});
let on_change_text = move |ev| {
let val = event_target_value(&ev);
value.set(val);
on_change.run(());
};
let on_blur_text = move |_| {
dirty.set(true);
};
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>
<input
node_ref=input_ref
class="border-b-2 mb-1.5 px-1.5 py-0.5 rounded-t-xs bg-transparent relative flex-1 transition-all duration-150
border-red-700 disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33"
r#type="text"
disabled=input_disabled
placeholder=placeholder
prop:value=value
on:input=on_change_text
on:blur=on_blur_text
/>
</label>
}
}
fn validate(value: String, required: bool) -> ValidationState { fn validate(value: String, required: bool) -> ValidationState {
if required && value.is_empty() { if required && value.is_empty() {
ValidationState::Invalid("This field is required".into()) ValidationState::Invalid("This field is required".into())
@@ -64,24 +151,47 @@ pub fn RadioList(
#[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
#[prop(optional, into)] required: Signal<bool>, #[prop(optional, into)] required: Signal<bool>,
#[prop(optional, into)] other_prompt: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
//#region Value Logic //#region Value Logic
// Store a single signal that is the currently selected value. We can only have one selected // 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 // value, so there's no list needed. The default selected value is whatever the map has selected
// already, or nothing. // 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); let hydrated = RwSignal::new(false);
let selected_value = RwSignal::new(String::new());
let other_checked = RwSignal::new(false);
let other_value = RwSignal::new(String::new());
// When the selected value changes, update the data map at the correct location // Hydration effect that resolves data. Sets up all data properly.
Effect::new({
let items = items.clone();
move || {
if hydrated.get() {
return;
}
let default_value = match data_map.get_untracked().get(&data_member.get_untracked()) {
Some(ValueType::String(Some(value))) => value.clone(),
_ => String::new()
};
if !items.contains(&default_value) {
other_checked.set(true);
other_value.set(default_value.clone());
}
selected_value.set(default_value);
hydrated.set(true);
}
});
// When the other option is selected and the other value changes, update the selected value.
Effect::new(move || { Effect::new(move || {
data_map.update(|map| { other_checked.get()
map.insert(data_member.get(), ValueType::String(Some(selected_value.get()))); .then(|| selected_value.set(other_value.get()));
});
}); });
//#endregion //#endregion
@@ -109,7 +219,15 @@ pub fn RadioList(
list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name) list.retain(|v| *v.get_untracked().input_name != *input_state.get_untracked().input_name)
}); });
}); });
// When the selected value changes, update the data map at the correct location and validate.
Effect::new(move || {
error.set(validate(selected_value.get(), required.get()));
data_map.update(|map| {
map.insert(data_member.get(), ValueType::String(Some(selected_value.get())));
});
});
//#endregion //#endregion
//#region Render Logic //#region Render Logic
@@ -123,12 +241,13 @@ pub fn RadioList(
let item = RwSignal::new(item); let item = RwSignal::new(item);
let checked_signal = Signal::derive(move || { let checked_signal = Signal::derive(move || {
selected_value.get() == item.get() selected_value.get() == item.get() && !other_checked.get()
}); });
let on_change = move || { let on_change = move || {
// on_change only runs when the radio button is checked, not when unchecked. // on_change only runs when the radio button is checked, not when unchecked.
// Change the selected value to the new one. // Change the selected value to the new one.
other_checked.set(false);
selected_value.set(item.get()); selected_value.set(item.get());
error.set(validate(selected_value.get_untracked(), required.get_untracked())); error.set(validate(selected_value.get_untracked(), required.get_untracked()));
dirty.set(true); dirty.set(true);
@@ -146,6 +265,18 @@ pub fn RadioList(
} }
}) })
.collect::<Vec<_>>()} .collect::<Vec<_>>()}
<Show when=move || !other_prompt.get().is_empty()>
<RadioOther
checked=other_checked
name=data_member
disabled=disabled
value=other_value
on_change=move |_| other_checked.set(true)
placeholder=other_prompt
dirty=dirty
/>
</Show>
</div> </div>
<Show when=move || show_errors.get()> <Show when=move || show_errors.get()>
<div class="text-red-600 text-sm mr-1.5 ml-1.5"> <div class="text-red-600 text-sm mr-1.5 ml-1.5">
+1
View File
@@ -15,6 +15,7 @@ use crate::common::{
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[cfg(feature = "ssr")]
static COMPARISONS_TABLE: &str = "leptos-comparison-test"; static COMPARISONS_TABLE: &str = "leptos-comparison-test";
/// Helper function to create all sports comparisons. Only used to create initial /// Helper function to create all sports comparisons. Only used to create initial
+1
View File
@@ -17,6 +17,7 @@ use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError; use leptos::prelude::ServerFnError;
use leptos::server; use leptos::server;
#[cfg(feature = "ssr")]
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarship-test"; static SCHOLARSHIPS_TABLE: &str = "leptos-scholarship-test";
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] #[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
+2 -24
View File
@@ -8,7 +8,7 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::io::Write; use std::io::Write;
use crate::common::{ComparisonData, ExpandableInfo}; use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims; use crate::pages::utils::get_user_claims;
use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm}; use crate::components::{ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, TextFieldType, ValidatedForm};
@@ -22,7 +22,6 @@ use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement; use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast; use leptos::wasm_bindgen::JsCast;
use std::collections::HashMap; use std::collections::HashMap;
use crate::pages::api::get_comparisons_categorized;
/// # Get Student Info /// # Get Student Info
/// Gets a student's information given their `subject`. /// Gets a student's information given their `subject`.
@@ -210,28 +209,6 @@ pub fn HomePage() -> impl IntoView {
}; };
//#endregion //#endregion
let comparison_resource = Resource::new(
move || user_subject.get(),
async move |_| get_comparisons_categorized().await
);
let comparison_info = RwSignal::new(HashMap::<String, Vec<ComparisonData>>::new());
Effect::new(move || {
match comparison_resource.get() {
Some(Ok(comparisons)) => {
log!("Found comparison info: {:?}", comparisons);
comparison_info.set(comparisons);
}
Some(Err(err)) => {
log!("Error while getting comparisons: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
view! { view! {
<AuthLoaded fallback=Loading> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
@@ -331,6 +308,7 @@ pub fn HomePage() -> impl IntoView {
data_member="town" data_member="town"
data_map=form_data data_map=form_data
required=true required=true
other_prompt="Other..."
/> />
</Row> </Row>
<Row> <Row>