From 6ffc1d2d9b5ea3366c80d83d75f99b2f0250770c Mon Sep 17 00:00:00 2001 From: luoxiaozero <48741584+luoxiaozero@users.noreply.github.com> Date: Sat, 25 Jan 2025 23:10:20 +0800 Subject: [PATCH] Feat/rating (#369) * feat: adds Rating component * feat: Improve Rating * feat: adds RatingDisplay component * feat: Rating adds rules prop * fix: build nightly --- demo/src/app.rs | 2 + demo/src/pages/components.rs | 10 ++ demo_markdown/src/lib.rs | 2 + thaw/src/field/docs/mod.md | 3 + thaw/src/field/rule.rs | 12 +++ thaw/src/lib.rs | 2 + thaw/src/rating/docs/mod.md | 69 +++++++++++++ thaw/src/rating/docs/rating_display.md | 59 +++++++++++ thaw/src/rating/mod.rs | 6 ++ thaw/src/rating/rating/mod.rs | 108 +++++++++++++++++++++ thaw/src/rating/rating/rating.css | 129 +++++++++++++++++++++++++ thaw/src/rating/rating/types.rs | 114 ++++++++++++++++++++++ thaw/src/rating/rating_display/mod.rs | 62 ++++++++++++ thaw/src/rating/rating_item/mod.rs | 124 ++++++++++++++++++++++++ 14 files changed, 702 insertions(+) create mode 100644 thaw/src/rating/docs/mod.md create mode 100644 thaw/src/rating/docs/rating_display.md create mode 100644 thaw/src/rating/mod.rs create mode 100644 thaw/src/rating/rating/mod.rs create mode 100644 thaw/src/rating/rating/rating.css create mode 100644 thaw/src/rating/rating/types.rs create mode 100644 thaw/src/rating/rating_display/mod.rs create mode 100644 thaw/src/rating/rating_item/mod.rs diff --git a/demo/src/app.rs b/demo/src/app.rs index fbf179dc..1eafbf18 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -98,6 +98,8 @@ fn TheRouter() -> impl IntoView { + + diff --git a/demo/src/pages/components.rs b/demo/src/pages/components.rs index 3755ac73..3efd8bb8 100644 --- a/demo/src/pages/components.rs +++ b/demo/src/pages/components.rs @@ -359,6 +359,16 @@ pub(crate) fn gen_nav_data() -> Vec { value: "/components/radio", label: "Radio", }, + NavItemOption { + group: Some("Rating"), + value: "/components/rating", + label: "Rating", + }, + NavItemOption { + group: Some("Rating"), + value: "/components/rating-display", + label: "RatingDisplay", + }, NavItemOption { group: None, value: "/components/scrollbar", diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index c05a1be0..f3d682c3 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -62,6 +62,8 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "PopoverMdPage" => "../../thaw/src/popover/docs/mod.md", "ProgressBarMdPage" => "../../thaw/src/progress_bar/docs/mod.md", "RadioMdPage" => "../../thaw/src/radio/docs/mod.md", + "RatingMdPage" => "../../thaw/src/rating/docs/mod.md", + "RatingDisplayMdPage" => "../../thaw/src/rating/docs/rating_display.md", "ScrollbarMdPage" => "../../thaw/src/scrollbar/docs/mod.md", "SelectMdPage" => "../../thaw/src/select/docs/mod.md", "SkeletonMdPage" => "../../thaw/src/skeleton/docs/mod.md", diff --git a/thaw/src/field/docs/mod.md b/thaw/src/field/docs/mod.md index e86da8a3..3a96e03e 100644 --- a/thaw/src/field/docs/mod.md +++ b/thaw/src/field/docs/mod.md @@ -47,6 +47,9 @@ view! { + + + diff --git a/thaw/src/field/rule.rs b/thaw/src/field/rule.rs index 00d91b1c..d4baa63f 100644 --- a/thaw/src/field/rule.rs +++ b/thaw/src/field/rule.rs @@ -116,6 +116,18 @@ impl RuleValueWithUntracked for Model { } } +impl RuleValueWithUntracked> for OptionModel { + fn value_with_untracked( + &self, + f: impl FnOnce(&Option) -> Result<(), FieldValidationState>, + ) -> Result<(), FieldValidationState> { + self.with_untracked(move |v| match v { + OptionModelWithValue::T(v) => f(&Some(v.clone())), + OptionModelWithValue::Option(v) => f(v), + }) + } +} + impl RuleValueWithUntracked> for OptionModel { fn value_with_untracked( &self, diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 27992ea4..598b50b1 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -37,6 +37,7 @@ mod pagination; mod popover; mod progress_bar; mod radio; +mod rating; mod scrollbar; mod select; mod skeleton; @@ -94,6 +95,7 @@ pub use pagination::*; pub use popover::*; pub use progress_bar::*; pub use radio::*; +pub use rating::*; pub use scrollbar::*; pub use select::*; pub use skeleton::*; diff --git a/thaw/src/rating/docs/mod.md b/thaw/src/rating/docs/mod.md new file mode 100644 index 00000000..8e4a1799 --- /dev/null +++ b/thaw/src/rating/docs/mod.md @@ -0,0 +1,69 @@ +# Rating + +```rust demo +let value = RwSignal::new(0.0); + +view! { + {move || value.get()} + +} +``` + +### Step + +```rust demo +let value = RwSignal::new(3.5); + +view! { + {move || value.get()} + +} +``` + +### Max + +```rust demo +let value = RwSignal::new(5.0); + +view! { + +} +``` + +### Size + +```rust demo +view! { + + + + + + +} +``` + +### Color + +```rust demo +view! { + + + + +} +``` + +### Rating Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `MaybeProp` | `Default::default()` | | +| id | `MaybeProp` | `Default::default()` | | +| name | `MaybeProp` | `Default::default()` | Name for the Radio inputs. If not provided, one will be automatically generated. | +| rules | `Vec` | `vec![]` | The rules to validate Field. | +| value | `OptionModel` | `None` | The value of the rating. | +| max | `Signal` | `5` | The max value of the rating. This controls the number of rating items displayed. Must be a whole number greater than 1. | +| step | `Signal` | `1.0` | Sets the precision to allow half-filled shapes in Rating. | +| size | `Signal` | `RatingSize::ExtraLarge` | Sets the size of the Rating items. | +| color | `Signal` | `RatingColor::Neutral` | Rating color. | diff --git a/thaw/src/rating/docs/rating_display.md b/thaw/src/rating/docs/rating_display.md new file mode 100644 index 00000000..ebe4d63c --- /dev/null +++ b/thaw/src/rating/docs/rating_display.md @@ -0,0 +1,59 @@ +# RatingDisplay + +The value controls the number of filled stars, and is written out next to the RatingDisplay. The number of filled stars is rounded to the nearest half-star. + +```rust demo +view! { + + + + + + + +} +``` + +### Max + +```rust demo +let value = RwSignal::new(5.0); + +view! { + +} +``` + +### Size + +```rust demo +view! { + + + + + + +} +``` + +### Color + +```rust demo +view! { + + + + +} +``` + +### Rating Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `MaybeProp` | `Default::default()` | | +| value | `Signal` | `0.0` | The value of the rating. | +| max | `Signal` | `5` | The max value of the rating. This controls the number of rating items displayed. Must be a whole number greater than 1. | +| size | `Signal` | `RatingSize::Medium` | Sets the size of the Rating items. | +| color | `Signal` | `RatingColor::Neutral` | Rating color. | diff --git a/thaw/src/rating/mod.rs b/thaw/src/rating/mod.rs new file mode 100644 index 00000000..10b24da8 --- /dev/null +++ b/thaw/src/rating/mod.rs @@ -0,0 +1,6 @@ +mod rating; +mod rating_display; +mod rating_item; + +pub use rating::*; +pub use rating_display::*; diff --git a/thaw/src/rating/rating/mod.rs b/thaw/src/rating/rating/mod.rs new file mode 100644 index 00000000..0289172b --- /dev/null +++ b/thaw/src/rating/rating/mod.rs @@ -0,0 +1,108 @@ +mod types; + +pub use types::*; + +use super::rating_item::RatingItem; +use crate::{FieldInjection, Rule}; +use leptos::{context::Provider, prelude::*}; +use thaw_utils::{class_list, mount_style, OptionModel}; +use wasm_bindgen::JsCast; +use web_sys::{Event, EventTarget, HtmlInputElement, MouseEvent}; + +#[component] +pub fn Rating( + #[prop(optional, into)] class: MaybeProp, + #[prop(optional, into)] id: MaybeProp, + #[prop(optional, into)] rules: Vec, + /// Name for the Radio inputs. If not provided, one will be automatically generated. + #[prop(optional, into)] + name: MaybeProp, + /// The value of the rating. + #[prop(optional, into)] + value: OptionModel, + /// The max value of the rating. This controls the number of rating items displayed. + /// Must be a whole number greater than 1. + #[prop(default = 5.into(), into)] + max: Signal, + /// Sets the precision to allow half-filled shapes in Rating. + #[prop(default = 1.0.into(), into)] + step: Signal, + /// Sets the size of the Rating items. + #[prop(default = RatingSize::ExtraLarge.into(), into)] + size: Signal, + /// Rating color. + #[prop(optional, into)] + color: Signal, +) -> impl IntoView { + mount_style("rating", include_str!("./rating.css")); + let (id, name) = FieldInjection::use_id_and_name(id, name); + let validate = Rule::validate(rules, value, name); + + let name = Memo::new(move |_| { + name.get() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + }); + let hovered_value = RwSignal::new(None::); + + let on_change = move |e: Event| { + if let Some(el) = is_rating_radio_item(e.target().unwrap(), &name.read()) { + if let Ok(new_value) = el.value().parse::() { + value.set(Some(new_value)); + validate.run(Some(RatingRuleTrigger::Change)); + } + } + }; + + let on_mouseover = move |e: MouseEvent| { + if let Some(el) = is_rating_radio_item(e.target().unwrap(), &name.read()) { + if let Ok(new_value) = el.value().parse::() { + hovered_value.set(Some(new_value)); + } + } + }; + + let on_mouseleave = move |_| { + hovered_value.set(None); + }; + + view! { + + + {move || { + let mut max = max.get(); + if max < 2 { + max = 2; + } + (0..max) + .into_iter() + .map(|i| { + view! { } + }) + .collect_view() + }} + + + } +} + +fn is_rating_radio_item(target: EventTarget, name: &String) -> Option { + target + .dyn_into::() + .ok() + .filter(|el| el.type_() == "radio" && &el.name() == name) +} diff --git a/thaw/src/rating/rating/rating.css b/thaw/src/rating/rating/rating.css new file mode 100644 index 00000000..b921ba56 --- /dev/null +++ b/thaw/src/rating/rating/rating.css @@ -0,0 +1,129 @@ +.thaw-rating, +.thaw-rating-display { + display: flex; + flex-wrap: wrap; +} + +.thaw-rating-display { + align-items: center; +} + +.thaw-rating-display__value-text { + color: var(--colorNeutralForeground1); + margin-left: var(--spacingHorizontalXS); + font-family: var(--fontFamilyBase); + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + font-weight: var(--fontWeightSemibold); +} + +.thaw-rating-display--large .thaw-rating-display__value-text { + margin-left: var(--spacingHorizontalSNudge); + line-height: var(--lineHeightBase300); + font-size: var(--fontSizeBase300); +} + +.thaw-rating-display--extra-large .thaw-rating-display__value-text { + margin-left: var(--spacingHorizontalS); + font-size: var(--fontSizeBase400); + line-height: var(--lineHeightBase400); +} + +.thaw-rating-item { + position: relative; +} + +.thaw-rating-item--small { + height: 12px; + width: 12px; + font-size: 12px; +} + +.thaw-rating-item--medium { + height: 16px; + width: 16px; + font-size: 16px; +} + +.thaw-rating-item--large { + height: 20px; + width: 20px; + font-size: 20px; +} + +.thaw-rating-item--extra-large { + height: 28px; + width: 28px; + font-size: 28px; +} + +.thaw-rating-item__half-value-input { + position: absolute; + inset: 0px; + box-sizing: border-box; + margin: 0px; + opacity: 0; + cursor: pointer; + height: 100%; + + right: 50%; +} + +.thaw-rating-item__full-value-input { + position: absolute; + inset: 0px; + box-sizing: border-box; + margin: 0px; + opacity: 0; + cursor: pointer; + height: 100%; +} + +.thaw-rating-item__half-value-input + .thaw-rating-item__full-value-input { + left: 50%; +} + +.thaw-rating-item__unselected-icon, +.thaw-rating-item__selected-icon { + display: flex; + overflow: hidden; + color: var(--colorNeutralForeground1); + fill: currentcolor; + pointer-events: none; + position: absolute; + inset: 0px; +} + +.thaw-rating-item--brand .thaw-rating-item__unselected-icon, +.thaw-rating-item--brand .thaw-rating-item__selected-icon { + color: var(--colorBrandForeground1); +} + +.thaw-rating-item--filled .thaw-rating-item__unselected-icon { + stroke: var(--colorTransparentStroke); + color: var(--colorNeutralBackground6); +} + +.thaw-rating-item--filled.thaw-rating-item--brand + .thaw-rating-item__unselected-icon { + color: var(--colorBrandBackground2); +} + +.thaw-rating-item--half .thaw-rating-item__unselected-icon { + margin-left: -50%; + left: 50%; +} + +.thaw-rating-item--half .thaw-rating-item__selected-icon { + right: 50%; +} + +.thaw-rating-item__unselected-icon > svg, +.thaw-rating-item__selected-icon > svg { + display: inline; + line-height: 0; +} + +.thaw-rating-item--half .thaw-rating-item__selected-icon > svg { + flex: 0 0 auto; +} diff --git a/thaw/src/rating/rating/types.rs b/thaw/src/rating/rating/types.rs new file mode 100644 index 00000000..05e8c0f4 --- /dev/null +++ b/thaw/src/rating/rating/types.rs @@ -0,0 +1,114 @@ +use std::ops::Deref; + +use crate::{FieldValidationState, Rule}; +use leptos::prelude::*; +use thaw_utils::OptionModel; + +#[derive(Clone, Copy)] +pub(crate) struct RatingInjection { + pub value: OptionModel, + pub hovered_value: RwSignal>, + pub name: Memo, + pub step: Signal, + pub size: Signal, + pub color: Signal, + pub interactive: bool, +} + +impl RatingInjection { + pub fn expect_context() -> Self { + expect_context() + } +} + +#[derive(Default, Clone)] +pub enum RatingColor { + Brand, + // TODO v0.5 Marigold, + #[default] + Neutral, +} + +impl RatingColor { + pub fn as_str(&self) -> &'static str { + match self { + Self::Brand => "brand", + // RatingColor::Marigold => "marigold", + Self::Neutral => "neutral", + } + } +} + +#[derive(Clone)] +pub enum RatingSize { + Small, + Medium, + Large, + ExtraLarge, +} + +impl RatingSize { + pub fn as_str(&self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Large => "large", + Self::ExtraLarge => "extra-large", + } + } +} + +#[derive(Debug, Default, PartialEq, Clone, Copy)] +pub enum RatingRuleTrigger { + #[default] + Change, +} + +pub struct RatingRule(Rule, RatingRuleTrigger>); + +impl RatingRule { + pub fn required(required: Signal) -> Self { + Self::validator(move |value, name| { + if required.get_untracked() && value.is_none() { + let message = name.get_untracked().map_or_else( + || String::from("Please select!"), + |name| format!("Please select {name}!"), + ); + Err(FieldValidationState::Error(message)) + } else { + Ok(()) + } + }) + } + + pub fn required_with_message(required: Signal, message: Signal) -> Self { + Self::validator(move |value, _| { + if required.get_untracked() && value.is_none() { + Err(FieldValidationState::Error(message.get_untracked())) + } else { + Ok(()) + } + }) + } + + pub fn validator( + f: impl Fn(&Option, Signal>) -> Result<(), FieldValidationState> + + Send + + Sync + + 'static, + ) -> Self { + Self(Rule::validator(f)) + } + + pub fn with_trigger(self, trigger: RatingRuleTrigger) -> Self { + Self(Rule::with_trigger(self.0, trigger)) + } +} + +impl Deref for RatingRule { + type Target = Rule, RatingRuleTrigger>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/thaw/src/rating/rating_display/mod.rs b/thaw/src/rating/rating_display/mod.rs new file mode 100644 index 00000000..dc99ef9c --- /dev/null +++ b/thaw/src/rating/rating_display/mod.rs @@ -0,0 +1,62 @@ +use crate::RatingInjection; + +use super::{rating_item::RatingItem, RatingColor, RatingSize}; +use leptos::{context::Provider, prelude::*, reactive::wrappers::write::SignalSetter}; +use thaw_utils::{class_list, mount_style}; + +#[component] +pub fn RatingDisplay( + #[prop(optional, into)] class: MaybeProp, + /// The value of the rating. + #[prop(optional, into)] + value: Signal, + /// The max value of the rating. This controls the number of rating items displayed. + /// Must be a whole number greater than 1. + #[prop(default = 5.into(), into)] + max: Signal, + /// Sets the size of the Rating items. + #[prop(default = RatingSize::Medium.into(), into)] + size: Signal, + /// Rating color. + #[prop(optional, into)] + color: Signal, +) -> impl IntoView { + mount_style("rating", include_str!("../rating/rating.css")); + + view! { + + ), + name: Memo::new(move |_| String::new()), + step: 0.5.into(), + size, + color, + interactive: false, + }> + {move || { + let mut max = max.get(); + if max < 2 { + max = 2; + } + (0..max) + .into_iter() + .map(|i| { + view! { } + }) + .collect_view() + }} + + + {move || value.get()} + + + } +} diff --git a/thaw/src/rating/rating_item/mod.rs b/thaw/src/rating/rating_item/mod.rs new file mode 100644 index 00000000..59e6e617 --- /dev/null +++ b/thaw/src/rating/rating_item/mod.rs @@ -0,0 +1,124 @@ +use crate::RatingInjection; +use leptos::{either::Either, prelude::*}; +use thaw_components::{If, Then}; +use thaw_utils::class_list; + +#[component] +pub fn RatingItem(value: u8) -> impl IntoView { + let rating = RatingInjection::expect_context(); + + let icon_fill_width = Memo::new(move |_| { + let displayed_rating_value = rating + .hovered_value + .get() + .unwrap_or_else(|| (rating.value.get().unwrap_or_default() * 2.0).round() / 2.0); + let value = f32::from(value); + + if displayed_rating_value >= value { + 1.0 + } else if displayed_rating_value >= value - 0.5 { + 0.5 + } else { + 0.0 + } + }); + + let half = Signal::derive(move || rating.step.get() == 0.5); + + view! { + + {if rating.interactive { + Either::Left( + view! { + + + + + + + }, + ) + } else { + Either::Right(()) + }} + + + {if rating.interactive { + Either::Left( + view! { + + + + }, + ) + } else { + Either::Right( + view! { + + + + }, + ) + }} + + + 0.0)> + + + + + + + + + + } +}