refactor: move all lists into their own module
This commit is contained in:
1 parent
d9951000e2
commit
f08fee1246
9 files changed
+22
-10
No files matched your search
@@ -0,0 +1,182 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use super::utils::use_selectable_list;
|
||||
use crate::components::ValidationState;
|
||||
|
||||
/// # Checkbox Component
|
||||
/// This component should only be used from within a [`CheckboxList`] component.
|
||||
///
|
||||
/// It creates a single checkbox with a specific value. Indicating if it is checked is done
|
||||
/// _externally_ from the component, and requires some sort of signal. The component itself
|
||||
/// does not track whether it is checked, it only runs the `on_change` callback.
|
||||
///
|
||||
/// Example usage (from within a [`CheckboxList`]:
|
||||
/// ```
|
||||
/// let disabled_signal = RwSignal::new(false);
|
||||
/// let checked_signal = Signal::derive(move || {
|
||||
/// // It's recommended to use a derived signal of some sort, but if you want to create
|
||||
/// // a manually controlled RwSignal, that's fine too.
|
||||
/// true
|
||||
/// });
|
||||
///
|
||||
/// view! {
|
||||
/// <Checkbox
|
||||
/// checked=checked_signal
|
||||
/// on_change=move || log!("A callback that's run on change.")
|
||||
/// name="some_name"
|
||||
/// disabled=disabled_signal
|
||||
/// value="Some Value"
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
/// A [`Signal`] indicating whether the checkbox is checked.
|
||||
#[prop()] checked: Signal<bool>,
|
||||
/// A callback that is run when the checkbox is checked and unchecked.
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
/// The value of the checkbox.
|
||||
#[prop(into)] value: Signal<String>,
|
||||
/// The name of the checkbox. This is used to group checkboxes together, so every checkbox
|
||||
/// in a group should have the same name.
|
||||
#[prop(into)] name: String,
|
||||
/// A [`Signal`] indicating whether the checkbox is disabled.
|
||||
#[prop(into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let id = create_unique_id(&name, &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="checkbox"
|
||||
id=id.clone()
|
||||
name=name.clone()
|
||||
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-sm border border-gray-300 bg-white
|
||||
transition-bg duration-150 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-3 w-1.5 -translate-x-1/2 -translate-y-1/2
|
||||
rotate-45 transform border-b-3 border-r-3
|
||||
border-white transition-opacity duration-150"></span>
|
||||
</div>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Checkbox List Component
|
||||
/// This component creates a list of checkboxes, each with a specific value. The values are passed
|
||||
/// through a prop rather than manually creating children.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// let items = vec! [
|
||||
/// "Item 1",
|
||||
/// "Item 2",
|
||||
/// "Item 3"
|
||||
/// ];
|
||||
///
|
||||
/// view! {
|
||||
/// <CheckboxList
|
||||
/// data_member="data_map_member"
|
||||
/// data_map=RwSignal::new(HashMap::new())
|
||||
/// items=items
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn CheckboxList(
|
||||
/// The data member that this list should edit/display from the `data_map`.
|
||||
#[prop(into)] data_member: String,
|
||||
/// A [`Signal`] allowing access to a data store.
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
/// The available choices for this list.
|
||||
#[prop()] items: Vec<String>,
|
||||
/// A [`Signal`] indicating whether the checkbox list is disabled.
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
/// A label for the list. Shown like a field name in a form.
|
||||
#[prop(optional, into)] label: String,
|
||||
/// A [`Signal`] indicating whether the list is required. Used for validation.
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
//#region Render Logic
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let item = Signal::derive({
|
||||
let cloned = item.clone();
|
||||
move || cloned.clone()
|
||||
});
|
||||
|
||||
let checked = Signal::derive(move || {
|
||||
controller.selected_list.get().contains(&item.get())
|
||||
});
|
||||
|
||||
let on_change = move || {
|
||||
// Update the data list by checking if the value is present in it. If so,
|
||||
// remove it. If not, add it.
|
||||
controller.selected_list.update(|list| {
|
||||
let item = item.get();
|
||||
if list.contains(&item) {
|
||||
list.retain(|val| *val != item);
|
||||
} else {
|
||||
list.push(item);
|
||||
}
|
||||
});
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Checkbox
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
// The actual selected values are tracked by this element, not by the checkboxes themselves.
|
||||
value=item
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::common::ValueType;
|
||||
use crate::components::utils::create_unique_id;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::ValidationState;
|
||||
use super::utils::use_selectable_list;
|
||||
|
||||
/// # Chip Component
|
||||
///
|
||||
/// This component defines a single chip for use in the `ChipsList` component. It functions almost
|
||||
/// identically to the `Checkbox` component. It should only be created from within its parent
|
||||
/// `CheckboxList` component.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// view! {
|
||||
/// <Chip
|
||||
/// value="Some Value"
|
||||
/// name="list_name"
|
||||
/// on_change=move || { println!("Some work here.") }
|
||||
/// checked=RwSignal::new(false)
|
||||
/// disabled=RwSignal::new(false)
|
||||
/// />
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn Chip(
|
||||
#[prop(into)] display_text: Signal<String>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
#[prop(into)] name: String,
|
||||
#[prop(into)] on_change: Callback<(), ()>,
|
||||
#[prop()] checked: Signal<bool>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Generate a unique id - ensure that the value doesn't contain spaces.
|
||||
// Without this id, checkbox/radio inputs can interfere with each other.
|
||||
let id = create_unique_id(&name, &value.get_untracked());
|
||||
|
||||
view! {
|
||||
<label for=id>
|
||||
<input
|
||||
r#type="checkbox"
|
||||
class="relative peer shrink-0 hidden"
|
||||
name=name.clone()
|
||||
id=id.clone()
|
||||
prop:checked=checked
|
||||
on:change=move |_| on_change.run(())
|
||||
disabled=disabled />
|
||||
<div class="rounded-full p-2.5
|
||||
cursor-pointer text-base
|
||||
bg-transparent border-1 border-gray-300
|
||||
shadow-md/50 hover:shadow-md/70
|
||||
peer-checked:bg-red-700 peer-checked:border-red-700 peer-checked:text-white
|
||||
peer-disabled:peer-checked:bg-gray-700
|
||||
peer-disabled:peer-checked:border-gray-700
|
||||
peer-disabled:cursor-default
|
||||
transition-all duration-150">
|
||||
<span>{display_text.clone()}</span>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// # Chips List Component
|
||||
///
|
||||
/// This defines a list of `Chip` components. The list of values is created based on the data from the
|
||||
/// `data_map` prop, which is found using the given `data_member`.
|
||||
///
|
||||
/// Any values selected from the list are stored within the `data_map` immediately as part of a callback
|
||||
/// passed to the `on_change` prop for each individual `Chip` component.
|
||||
#[component]
|
||||
pub fn ChipsList(
|
||||
#[prop(into)] data_member: String,
|
||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
||||
#[prop(into)] values: Signal<Vec<String>>,
|
||||
#[prop(optional, into)] disabled: Signal<bool>,
|
||||
#[prop(optional, into)] label: String,
|
||||
#[prop(optional, into)] required: Signal<bool>
|
||||
) -> impl IntoView {
|
||||
let controller = use_selectable_list(
|
||||
data_member.clone(),
|
||||
data_map.clone(),
|
||||
required.clone()
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col">
|
||||
<div class="m-1.5 mt-0 mb-0">
|
||||
<span class="font-bold">{label}</span>
|
||||
{displayed_text.get().into_iter().zip(values.get())
|
||||
.into_iter()
|
||||
.map(|(text, value)| {
|
||||
let value = RwSignal::new(value);
|
||||
|
||||
let checked = Signal::derive(move || {
|
||||
controller.selected_list.get().contains(&value.get())
|
||||
});
|
||||
|
||||
let on_change = move || {
|
||||
// Update the data list by checking if the value is present in it. If so,
|
||||
// remove it. If not, add it.
|
||||
controller.selected_list.update(|list| {
|
||||
let value = value.get();
|
||||
if list.contains(&value) {
|
||||
list.retain(|val| *val != value);
|
||||
} else {
|
||||
list.push(value.clone());
|
||||
}
|
||||
});
|
||||
controller.dirty.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<Chip
|
||||
checked=checked
|
||||
on_change=on_change
|
||||
value=value
|
||||
display_text=text
|
||||
name=data_member.clone()
|
||||
disabled=disabled
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
<Show when=move || controller.show_errors.get()>
|
||||
<div class="text-red-600 text-sm mr-1.5 ml-1.5">
|
||||
{move || {
|
||||
match controller.error.get() {
|
||||
ValidationState::Invalid(msg) => msg,
|
||||
_ => "There is no error - should not see this message.".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod utils;
|
||||
mod checkbox;
|
||||
mod chips;
|
||||
|
||||
pub use checkbox::*;
|
||||
pub use chips::*;
|
||||
@@ -0,0 +1,93 @@
|
||||
use leptos::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::common::ValueType;
|
||||
use crate::components::{use_validation_context, InputState, ValidationState};
|
||||
|
||||
pub struct ListController {
|
||||
pub error: Signal<ValidationState>,
|
||||
pub dirty: RwSignal<bool>,
|
||||
pub show_errors: Signal<bool>,
|
||||
pub validator: RwSignal<InputState>,
|
||||
pub selected_list: RwSignal<Vec<String>>,
|
||||
}
|
||||
|
||||
fn value_type_to_vec(value_type: &ValueType) -> Vec<String> {
|
||||
let ValueType::List(Some(list)) = value_type else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
list.iter().map(|val| {
|
||||
val.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default()
|
||||
}).collect::<Vec<String>>()
|
||||
}
|
||||
|
||||
pub fn use_selectable_list(
|
||||
data_member: String,
|
||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
||||
required: Signal<bool>
|
||||
) -> ListController {
|
||||
//#region Central List Logic
|
||||
|
||||
// Get default values from the data map. This will only run once, when the input is created
|
||||
// and mounted to the DOM.
|
||||
let temp_map = data_map.get_untracked();
|
||||
let origin_value_type = temp_map.get(&data_member)
|
||||
.unwrap_or(&ValueType::List(None));
|
||||
let origin_list = value_type_to_vec(origin_value_type);
|
||||
|
||||
// "Raw" selected values - just strings, no ValueTyping
|
||||
let selected_list = RwSignal::new(origin_list);
|
||||
|
||||
Effect::new({
|
||||
let data_member = data_member.clone();
|
||||
move || {
|
||||
// Convert selected strings into ValueTypes
|
||||
let typed_list = selected_list.with(|list|
|
||||
list.iter().cloned().map(|val|
|
||||
ValueType::String(Some(val))
|
||||
).collect::<Vec<ValueType>>()
|
||||
);
|
||||
|
||||
// Update the map
|
||||
data_map.update(|map| {
|
||||
map.insert(data_member.clone(), ValueType::List(Some(typed_list)));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region Form Validation
|
||||
|
||||
let validation_context = use_validation_context()
|
||||
.expect("FormValidationRegistry was not found");
|
||||
|
||||
let error = Signal::derive(move || {
|
||||
if required.get() && selected_list.get().len() <= 0 {
|
||||
ValidationState::Invalid("This field is required.".to_string())
|
||||
} else {
|
||||
ValidationState::Valid
|
||||
}
|
||||
});
|
||||
let dirty = RwSignal::new(false);
|
||||
let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_)));
|
||||
|
||||
let validator = RwSignal::new(InputState::new(
|
||||
data_member.clone(),
|
||||
error.clone(),
|
||||
dirty.clone()
|
||||
));
|
||||
|
||||
validation_context.validators.update(|list| list.push(validator.clone()));
|
||||
|
||||
//#endregion
|
||||
|
||||
ListController {
|
||||
error,
|
||||
dirty,
|
||||
show_errors,
|
||||
validator,
|
||||
selected_list,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user