From 324bc57e42dc665e2a0be3d440648e151b2570d1 Mon Sep 17 00:00:00 2001 From: Ari Seyhun Date: Tue, 23 Apr 2024 09:41:01 +0800 Subject: [PATCH] feat: Add support for multiple options in Select (#166) * feat!: Add support for multiple options in Select * feat: Sync Select menu when value is updated * feat: Decrease line height of multi select items * fix: Invalid callback on multiselect tag close * feat: Sync Select menu only for multiple values * feat: separate `MultiSelect` from `Select` component * fix: SelectLabel slot implementation * feat: rename `MultiSelect` to `SelectMulti` * feat: rename and move `MultiSelect` * feat: add arrow to select component * feat: lower opacity of select dropdown arrow icon * fix: inconsistent select font size * fix: select menu font size * feat: add clear button to multi select * fix: Multi select icon on click attribute * feat: use inline-block for select component * feat: detect select min width based on options * feat: add `allow_clear` prop to `MultiSelect` * feat: remove select min width detection * feat: use `Children` for `SelectLabel` * feat: rename `allow_clear` to `clearable` * fix: follower min width * feat: remove inline-block from `Select` --- demo/src/components/switch_version.rs | 15 +- demo_markdown/docs/select/mod.md | 52 +++++-- demo_markdown/src/lib.rs | 12 +- thaw/src/icon/mod.rs | 9 ++ thaw/src/select/mod.rs | 216 ++++++++------------------ thaw/src/select/multi.rs | 199 ++++++++++++++++++++++++ thaw/src/select/raw.rs | 158 +++++++++++++++++++ thaw/src/select/select.css | 31 +++- thaw/src/tag/mod.rs | 2 +- thaw_components/src/binder/mod.rs | 3 + 10 files changed, 517 insertions(+), 180 deletions(-) create mode 100644 thaw/src/select/multi.rs create mode 100644 thaw/src/select/raw.rs diff --git a/demo/src/components/switch_version.rs b/demo/src/components/switch_version.rs index 22d7486..c78933f 100644 --- a/demo/src/components/switch_version.rs +++ b/demo/src/components/switch_version.rs @@ -4,18 +4,9 @@ use thaw::*; #[component] pub fn SwitchVersion() -> impl IntoView { let options = vec![ - SelectOption { - label: "main".into(), - value: "https://thawui.vercel.app".into(), - }, - SelectOption { - label: "0.2.6".into(), - value: "https://thaw-mzh1656cm-thaw.vercel.app".into(), - }, - SelectOption { - label: "0.2.5".into(), - value: "https://thaw-8og1kv8zs-thaw.vercel.app".into(), - }, + SelectOption::new("main", "https://thawui.vercel.app".into()), + SelectOption::new("0.2.6", "https://thaw-mzh1656cm-thaw.vercel.app".into()), + SelectOption::new("0.2.5", "https://thaw-8og1kv8zs-thaw.vercel.app".into()), ]; cfg_if::cfg_if! { diff --git a/demo_markdown/docs/select/mod.md b/demo_markdown/docs/select/mod.md index 25934eb..38eddb2 100644 --- a/demo_markdown/docs/select/mod.md +++ b/demo_markdown/docs/select/mod.md @@ -4,18 +4,37 @@ let value = create_rw_signal(None::); let options = vec![ - SelectOption { - label: String::from("RwSignal"), - value: String::from("rw_signal"), - }, - SelectOption { - label: String::from("Memo"), - value: String::from("memo"), - }, + SelectOption::new("RwSignal", String::from("rw_signal")), + SelectOption::new("Memo", String::from("memo")), ]; view! { - +} +``` + +# Multiple Select + +```rust demo +let value = create_rw_signal(vec![ + "rust".to_string(), + "javascript".to_string(), + "zig".to_string(), + "python".to_string(), + "cpp".to_string(), +]); + +let options = vec![ + MultiSelectOption::new("Rust", String::from("rust")).with_variant(TagVariant::Success), + MultiSelectOption::new("JavaScript", String::from("javascript")), + MultiSelectOption::new("Python", String::from("python")).with_variant(TagVariant::Warning), + MultiSelectOption::new("C++", String::from("cpp")).with_variant(TagVariant::Error), + MultiSelectOption::new("Lua", String::from("lua")), + MultiSelectOption::new("Zig", String::from("zig")), +]; + +view! { + } ``` @@ -26,3 +45,18 @@ view! { | class | `OptionalProp>` | `Default::default()` | Addtional classes for the select element. | | value | `Model>` | `None` | Checked value. | | options | `MaybeSignal>>` | `vec![]` | Options that can be selected. | + +### Multiple Select Props + +| Name | Type | Default | Description | +| --------- | ----------------------------------- | -------------------- | ----------------------------------------- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the select element. | +| value | `Model>` | `vec![]` | Checked values. | +| options | `MaybeSignal>>` | `vec![]` | Options that can be selected. | +| clearable | `MaybeSignal` | `false` | Allow the options to be cleared. | + +### Select Slots + +| Name | Default | Description | +| ----------- | ------- | ------------- | +| SelectLabel | `None` | Select label. | diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index 4794c2c..30e7bd7 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -8,11 +8,11 @@ use syn::ItemFn; macro_rules! file_path { ($($key:expr => $value:expr),*) => { { - let mut pairs = Vec::new(); - $( - pairs.push(($key, include_str!($value))); - )* - pairs + vec![ + $( + ($key, include_str!($value)), + )* + ] } } } @@ -111,7 +111,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt }) .map(|demo| { syn::parse_str::(&demo) - .expect(&format!("Cannot be resolved as a function: \n {demo}")) + .unwrap_or_else(|_| panic!("Cannot be resolved as a function: \n {demo}")) }) .collect(); diff --git a/thaw/src/icon/mod.rs b/thaw/src/icon/mod.rs index be76bc8..3bb6fc1 100644 --- a/thaw/src/icon/mod.rs +++ b/thaw/src/icon/mod.rs @@ -20,6 +20,9 @@ pub fn Icon( /// HTML style attribute. #[prop(into, optional)] style: Option>, + /// Callback when clicking on the icon. + #[prop(optional, into)] + on_click: Option>, ) -> impl IntoView { let icon_style = RwSignal::new(None); let icon_x = RwSignal::new(None); @@ -33,6 +36,11 @@ pub fn Icon( let icon_stroke = RwSignal::new(None); let icon_fill = RwSignal::new(None); let icon_data = RwSignal::new(None); + let on_click = move |ev| { + if let Some(click) = on_click.as_ref() { + click.call(ev); + } + }; create_isomorphic_effect(move |_| { let icon = icon.get(); @@ -84,6 +92,7 @@ pub fn Icon( stroke=move || take(icon_stroke) fill=move || take(icon_fill) inner_html=move || take(icon_data) + on:click=on_click > } } diff --git a/thaw/src/select/mod.rs b/thaw/src/select/mod.rs index 26c0214..12fa91b 100644 --- a/thaw/src/select/mod.rs +++ b/thaw/src/select/mod.rs @@ -1,177 +1,95 @@ +mod multi; +mod raw; mod theme; +pub use multi::*; pub use theme::SelectTheme; -use crate::{theme::use_theme, Theme}; use leptos::*; -use std::hash::Hash; -use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement, FollowerWidth}; -use thaw_utils::{class_list, mount_style, Model, OptionalProp}; +use std::{hash::Hash, rc::Rc}; +use thaw_utils::{Model, OptionalProp}; -#[derive(Clone, PartialEq, Eq, Hash)] +use crate::{ + select::raw::{RawSelect, SelectIcon}, + Icon, +}; + +#[slot] +pub struct SelectLabel { + children: Children, +} + +#[derive(Clone, Default, PartialEq, Eq, Hash)] pub struct SelectOption { pub label: String, pub value: T, } +impl SelectOption { + pub fn new(label: impl Into, value: T) -> SelectOption { + SelectOption { + label: label.into(), + value, + } + } +} + #[component] pub fn Select( #[prop(optional, into)] value: Model>, #[prop(optional, into)] options: MaybeSignal>>, #[prop(optional, into)] class: OptionalProp>, + #[prop(optional)] select_label: Option, ) -> impl IntoView where T: Eq + Hash + Clone + 'static, { - mount_style("select", include_str!("./select.css")); - - let theme = use_theme(Theme::light); - let css_vars = create_memo(move |_| { - let mut css_vars = String::new(); - theme.with(|theme| { - let border_color_hover = theme.common.color_primary.clone(); - css_vars.push_str(&format!("--thaw-border-color-hover: {border_color_hover};")); - css_vars.push_str(&format!( - "--thaw-background-color: {};", - theme.select.background_color - )); - css_vars.push_str(&format!("--thaw-font-color: {};", theme.select.font_color)); - css_vars.push_str(&format!( - "--thaw-border-color: {};", - theme.select.border_color - )); + let is_menu_visible = create_rw_signal(false); + let show_menu = move |_| is_menu_visible.set(true); + let hide_menu = move |_| is_menu_visible.set(false); + let is_selected = move |v: &T| with!(|value| value.as_ref() == Some(v)); + let on_select: Callback<(ev::MouseEvent, SelectOption)> = + Callback::new(move |(_, option): (ev::MouseEvent, SelectOption)| { + let item_value = option.value; + value.set(Some(item_value)); + hide_menu(()); }); - - css_vars - }); - - let menu_css_vars = create_memo(move |_| { - let mut css_vars = String::new(); - theme.with(|theme| { - css_vars.push_str(&format!( - "--thaw-background-color: {};", - theme.select.menu_background_color - )); - css_vars.push_str(&format!( - "--thaw-background-color-hover: {};", - theme.select.menu_background_color_hover - )); - css_vars.push_str(&format!("--thaw-font-color: {};", theme.select.font_color)); - css_vars.push_str(&format!( - "--thaw-font-color-selected: {};", - theme.common.color_primary - )); + let select_label = select_label.unwrap_or_else(|| { + let options = options.clone(); + let value_label = Signal::derive(move || { + with!(|value, options| { + match value { + Some(value) => options + .iter() + .find(|opt| &opt.value == value) + .map_or(String::new(), |v| v.label.clone()), + None => String::new(), + } + }) }); - css_vars + SelectLabel { + children: Box::new(move || Fragment::new(vec![value_label.into_view()])), + } }); - - let is_show_menu = create_rw_signal(false); - let trigger_ref = create_node_ref::(); - let menu_ref = create_node_ref::(); - let show_menu = move |_| { - is_show_menu.set(true); + let select_icon = SelectIcon { + children: Rc::new(move || { + Fragment::new(vec![ + view! { }.into_view() + ]) + }), }; - #[cfg(any(feature = "csr", feature = "hydrate"))] - { - use leptos::wasm_bindgen::__rt::IntoJsResult; - let timer = window_event_listener(ev::click, move |ev| { - let el = ev.target(); - let mut el: Option = - el.into_js_result().map_or(None, |el| Some(el.into())); - let body = document().body().unwrap(); - while let Some(current_el) = el { - if current_el == *body { - break; - }; - if current_el == ***menu_ref.get().unwrap() - || current_el == ***trigger_ref.get().unwrap() - { - return; - } - el = current_el.parent_element(); - } - is_show_menu.set(false); - }); - on_cleanup(move || timer.remove()); - } - - let temp_options = options.clone(); - let select_option_label = create_memo(move |_| match value.get() { - Some(value) => temp_options - .get() - .iter() - .find(move |v| v.value == value) - .map_or(String::new(), |v| v.label.clone()), - None => String::new(), - }); - view! { - -
- - {move || select_option_label.get()} - -
- - -
- - {item.get_value().label} -
- } - } - /> - - -
-
-
+ } } diff --git a/thaw/src/select/multi.rs b/thaw/src/select/multi.rs new file mode 100644 index 0000000..715ab51 --- /dev/null +++ b/thaw/src/select/multi.rs @@ -0,0 +1,199 @@ +use leptos::*; +use std::{hash::Hash, rc::Rc, time::Duration}; +use thaw_utils::{Model, OptionalProp}; + +use crate::{ + select::raw::{RawSelect, SelectIcon}, + Icon, SelectLabel, SelectOption, Tag, TagVariant, +}; + +#[derive(Clone, Default, PartialEq, Eq, Hash)] +pub struct MultiSelectOption { + pub label: String, + pub value: T, + pub variant: TagVariant, +} + +impl MultiSelectOption { + pub fn new(label: impl Into, value: T) -> MultiSelectOption { + MultiSelectOption { + label: label.into(), + value, + variant: TagVariant::Default, + } + } + + pub fn with_variant(mut self, variant: TagVariant) -> MultiSelectOption { + self.variant = variant; + self + } +} + +impl From> for SelectOption { + fn from(opt: MultiSelectOption) -> Self { + SelectOption { + label: opt.label, + value: opt.value, + } + } +} + +#[component] +pub fn MultiSelect( + #[prop(optional, into)] value: Model>, + #[prop(optional, into)] options: MaybeSignal>>, + #[prop(optional, into)] class: OptionalProp>, + #[prop(optional, into)] clearable: MaybeSignal, + #[prop(optional)] select_label: Option, +) -> impl IntoView +where + T: Eq + Hash + Clone + 'static, +{ + let select_options: Signal> = Signal::derive({ + let options = options.clone(); + move || options.get().into_iter().map(SelectOption::from).collect() + }); + let class: OptionalProp<_> = match class.into_option() { + Some(MaybeSignal::Dynamic(class)) => { + Some(MaybeSignal::Dynamic(Signal::derive(move || { + with!(|class| format!("thaw-select--multiple {class}")) + }))) + .into() + } + Some(MaybeSignal::Static(class)) => Some(MaybeSignal::Static(format!( + "thaw-select--multiple {class}" + ))) + .into(), + None => Some(MaybeSignal::Static("thaw-select--multiple".to_string())).into(), + }; + let is_menu_visible = create_rw_signal(false); + let show_menu = move |_| is_menu_visible.set(true); + let hide_menu = move |_| is_menu_visible.set(false); + let is_selected = move |v: &T| with!(|value| value.contains(v)); + let on_select: Callback<(ev::MouseEvent, SelectOption)> = + Callback::new(move |(_, option): (ev::MouseEvent, SelectOption)| { + let item_value = option.value; + update!(|value| { + let index = value + .iter() + .enumerate() + .find_map(|(i, v)| (v == &item_value).then_some(i)); + match index { + Some(i) => { + value.remove(i); + } + None => { + value.push(item_value); + } + } + }); + }); + let select_label = select_label.unwrap_or_else(|| { + let options = options.clone(); + let signal_value = value; + let value_label = Signal::derive(move || { + with!(|value, options| { + value + .iter() + .map(|value| { + let (label, variant) = options + .iter() + .find(move |v| &v.value == value) + .map_or((String::new(), TagVariant::Default), |v| { + (v.label.clone(), v.variant) + }); + let value = value.clone(); + let on_close = Callback::new(move |ev: ev::MouseEvent| { + ev.stop_propagation(); + let value = value.clone(); + // We remove the item on the next tick to ensure the menu on click handler works correctly + set_timeout( + move || { + update!(|signal_value| { + let index = signal_value + .iter() + .enumerate() + .find_map(|(i, v)| (v == &value).then_some(i)); + if let Some(i) = index { + signal_value.remove(i); + } + }); + }, + Duration::ZERO, + ) + }); + view! { + + {label} + + } + }) + .collect_view() + }) + }); + SelectLabel { + children: Box::new(move || Fragment::new(vec![value_label.into_view()])), + } + }); + let is_hovered = RwSignal::new(false); + let show_clear_icon = Signal::derive(move || { + clearable.get() + && ((is_hovered.get() || is_menu_visible.get()) && with!(|value| !value.is_empty())) + }); + let on_hover_enter = Callback::new(move |_| is_hovered.set(true)); + let on_hover_exit = Callback::new(move |_| is_hovered.set(false)); + let select_icon = SelectIcon { + children: Rc::new(move || { + Fragment::new(vec![view! { + {move || if show_clear_icon.get() { + view! { + + } + } else { + view! { + + } + }} + } + .into_view()]) + }), + }; + + // Trigger the following menu to resync when the value is updated + let _ = watch( + move || value.track(), + move |_, _, _| { + is_menu_visible.update(|_| {}); + }, + false, + ); + + view! { + + } +} diff --git a/thaw/src/select/raw.rs b/thaw/src/select/raw.rs new file mode 100644 index 0000000..7099f57 --- /dev/null +++ b/thaw/src/select/raw.rs @@ -0,0 +1,158 @@ +use std::{hash::Hash, time::Duration}; + +use leptos::*; +use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement, FollowerWidth}; +use thaw_utils::{class_list, mount_style, OptionalProp}; + +use crate::{theme::use_theme, SelectLabel, SelectOption, Theme}; + +#[slot] +pub(crate) struct SelectIcon { + children: ChildrenFn, +} + +#[component] +pub(super) fn RawSelect( + #[prop(optional, into)] options: MaybeSignal>>, + #[prop(optional, into)] class: OptionalProp>, + select_label: SelectLabel, + select_icon: SelectIcon, + #[prop(optional, into)] is_menu_visible: Signal, + #[prop(into)] on_select: Callback<(ev::MouseEvent, SelectOption)>, + #[prop(into)] show_menu: Callback<()>, + #[prop(into)] hide_menu: Callback<()>, + #[prop(optional, into)] on_hover_enter: Option>, + #[prop(optional, into)] on_hover_exit: Option>, + is_selected: F, +) -> impl IntoView +where + T: Eq + Hash + Clone + 'static, + F: Fn(&T) -> bool + Copy + 'static, +{ + mount_style("select", include_str!("./select.css")); + + let trigger_ref = create_node_ref::(); + let menu_ref = create_node_ref::(); + + #[cfg(any(feature = "csr", feature = "hydrate"))] + { + use leptos::wasm_bindgen::__rt::IntoJsResult; + let listener = window_event_listener(ev::click, move |ev| { + let el = ev.target(); + let el: Option = el.into_js_result().map_or(None, |el| Some(el.into())); + let is_descendent_of_select = trigger_ref.get().unwrap().contains(el.as_ref()); + let is_descendent_of_menu = menu_ref.get().unwrap().contains(el.as_ref()); + if (!is_descendent_of_select && !is_descendent_of_menu) + || (is_menu_visible.get() && el.unwrap() == ****trigger_ref.get().unwrap()) + { + hide_menu.call(()); + } + }); + on_cleanup(move || listener.remove()); + } + + let theme = use_theme(Theme::light); + let css_vars = create_memo(move |_| { + let mut css_vars = String::new(); + theme.with(|theme| { + let border_color_hover = theme.common.color_primary.clone(); + css_vars.push_str(&format!("--thaw-border-color-hover: {border_color_hover};")); + css_vars.push_str(&format!( + "--thaw-background-color: {};", + theme.select.background_color + )); + css_vars.push_str(&format!("--thaw-font-color: {};", theme.select.font_color)); + css_vars.push_str(&format!( + "--thaw-border-color: {};", + theme.select.border_color + )); + }); + + css_vars + }); + + let menu_css_vars = create_memo(move |_| { + let mut css_vars = String::new(); + theme.with(|theme| { + css_vars.push_str(&format!( + "--thaw-background-color: {};", + theme.select.menu_background_color + )); + css_vars.push_str(&format!( + "--thaw-background-color-hover: {};", + theme.select.menu_background_color_hover + )); + css_vars.push_str(&format!("--thaw-font-color: {};", theme.select.font_color)); + css_vars.push_str(&format!( + "--thaw-font-color-selected: {};", + theme.common.color_primary + )); + }); + css_vars + }); + + view! { + +
+ {(select_label.children)()} + {select_icon.children} +
+ + +
+ + {item.get_value().label} +
+ } + } + /> + +
+
+
+ } +} diff --git a/thaw/src/select/select.css b/thaw/src/select/select.css index 5a61d03..a7ff20a 100644 --- a/thaw/src/select/select.css +++ b/thaw/src/select/select.css @@ -1,9 +1,10 @@ .thaw-select { position: relative; - padding: 0 10px; - height: 30px; - line-height: 30px; + padding: 0 30px 0 10px; + min-height: 30px; + line-height: 28px; background-color: var(--thaw-background-color); + font-size: 14px; color: var(--thaw-font-color); border: 1px solid var(--thaw-border-color); border-radius: 3px; @@ -12,11 +13,35 @@ box-sizing: border-box; } +.thaw-select.thaw-select--multiple { + padding: 0 30px 0 3px; +} + +.thaw-select-dropdown-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + opacity: 40%; + pointer-events: none; +} + +.thaw-select-dropdown-icon--clear { + pointer-events: auto; +} + +.thaw-select.thaw-select--multiple .thaw-tag { + height: 20px; + margin-right: 3px; +} + .thaw-select:hover { border-color: var(--thaw-border-color-hover); } .thaw-select-menu { + font-size: 14px; color: var(--thaw-font-color); background-color: var(--thaw-background-color); box-sizing: border-box; diff --git a/thaw/src/tag/mod.rs b/thaw/src/tag/mod.rs index 67dbbd0..c1b0e22 100644 --- a/thaw/src/tag/mod.rs +++ b/thaw/src/tag/mod.rs @@ -6,7 +6,7 @@ use crate::{theme::use_theme, Icon, Theme}; use leptos::*; use thaw_utils::{class_list, mount_style, OptionalProp}; -#[derive(Clone, Default)] +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum TagVariant { #[default] Default, diff --git a/thaw_components/src/binder/mod.rs b/thaw_components/src/binder/mod.rs index fa5ed6d..d63e805 100644 --- a/thaw_components/src/binder/mod.rs +++ b/thaw_components/src/binder/mod.rs @@ -24,6 +24,8 @@ pub struct Follower { pub enum FollowerWidth { /// The popup width is the same as the target DOM width. Target, + /// The popup min width is the same as the target DOM width. + MinTarget, /// Customize the popup width. Px(u32), } @@ -181,6 +183,7 @@ fn FollowerContainer( if let Some(width) = width { let width = match width { FollowerWidth::Target => format!("width: {}px;", target_rect.width()), + FollowerWidth::MinTarget => format!("min-width: {}px;", target_rect.width()), FollowerWidth::Px(width) => format!("width: {width}px;"), }; style.push_str(&width);