diff --git a/demo/src/components/site_header.rs b/demo/src/components/site_header.rs index c5cb0e0..ff75af7 100644 --- a/demo/src/components/site_header.rs +++ b/demo/src/components/site_header.rs @@ -150,7 +150,7 @@ pub fn SiteHeader() -> impl IntoView { comp_ref=auto_complete_ref > - {option.label} + {option.label} - + {option.1} @@ -36,14 +36,6 @@ view! { } ``` -### Invalid - -```rust demo -view! { - -} -``` - ### Prefix & Suffix ```rust demo diff --git a/demo_markdown/docs/input/mod.md b/demo_markdown/docs/input/mod.md index 9fa6571..1b00cfd 100644 --- a/demo_markdown/docs/input/mod.md +++ b/demo_markdown/docs/input/mod.md @@ -54,16 +54,6 @@ view! { } ``` -### Invalid - -```rust demo -let value = RwSignal::new(String::from("o")); - -view! { - -} -``` - ### Imperative handle ```rust demo diff --git a/thaw/src/_aria/active_descendant/use_active_descendant.rs b/thaw/src/_aria/active_descendant/use_active_descendant.rs index 25048b8..df8c325 100644 --- a/thaw/src/_aria/active_descendant/use_active_descendant.rs +++ b/thaw/src/_aria/active_descendant/use_active_descendant.rs @@ -1,6 +1,7 @@ use super::use_option_walker::{use_option_walker, OptionWalker}; use send_wrapper::SendWrapper; use std::{cell::RefCell, sync::Arc}; +use thaw_utils::scroll_into_view; use web_sys::{HtmlElement, Node}; /// Applied to the element that is active descendant @@ -50,7 +51,7 @@ impl ActiveDescendantController { fn focus_active_descendant(&self, next_active: HtmlElement) { self.blur_active_descendant(); - + scroll_into_view(&next_active); let _ = next_active.set_attribute(ACTIVEDESCENDANT_ATTRIBUTE, ""); let _ = next_active.set_attribute(ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE, ""); diff --git a/thaw/src/auto_complete/auto-complete.css b/thaw/src/auto_complete/auto-complete.css index 3580831..6d2ca6a 100644 --- a/thaw/src/auto_complete/auto-complete.css +++ b/thaw/src/auto_complete/auto-complete.css @@ -33,34 +33,6 @@ div.thaw-auto-complete__listbox { background-color: var(--thaw-background-color-hover); } -.thaw-auto-complete__listbox.fade-in-scale-up-transition-leave-active { - transform-origin: inherit; - transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), - transform 0.2s cubic-bezier(0.4, 0, 1, 1), - background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.thaw-auto-complete__listbox.fade-in-scale-up-transition-enter-active { - transform-origin: inherit; - transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), - transform 0.2s cubic-bezier(0, 0, 0.2, 1), - background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.thaw-auto-complete__listbox.fade-in-scale-up-transition-enter-from, -.thaw-auto-complete__listbox.fade-in-scale-up-transition-leave-to { - opacity: 0; - transform: scale(0.9); -} - -.thaw-auto-complete__listbox.fade-in-scale-up-transition-leave-from, -.thaw-auto-complete__listbox.fade-in-scale-up-transition-enter-to { - opacity: 1; - transform: scale(1); -} - .thaw-auto-complete-option { column-gap: var(--spacingHorizontalXS); position: relative; @@ -75,6 +47,19 @@ div.thaw-auto-complete__listbox { cursor: pointer; } +.thaw-auto-complete-option[data-activedescendant-focusvisible]::after { + content: ""; + position: absolute; + right: -2px; + left: -2px; + bottom: -2px; + top: -2px; + z-index: 1; + pointer-events: none; + border-radius: var(--borderRadiusMedium); + border: 2px solid var(--colorStrokeFocus2); +} + .thaw-auto-complete-option:hover { color: var(--colorNeutralForeground1Hover); background-color: var(--colorNeutralBackground1Hover); diff --git a/thaw/src/auto_complete/auto_complete_option.rs b/thaw/src/auto_complete/auto_complete_option.rs index e517c12..0d400a7 100644 --- a/thaw/src/auto_complete/auto_complete_option.rs +++ b/thaw/src/auto_complete/auto_complete_option.rs @@ -2,17 +2,28 @@ use super::AutoCompleteInjection; use leptos::prelude::*; #[component] -pub fn AutoCompleteOption(key: String, children: Children) -> impl IntoView { - let value = key.clone(); - let auto_complete = AutoCompleteInjection::use_(); - let is_selected = Memo::new(move |_| auto_complete.is_selected(&key)); +pub fn AutoCompleteOption(value: String, children: Children) -> impl IntoView { + let auto_complete = AutoCompleteInjection::expect_context(); + let is_selected = Memo::new({ + let value = value.clone(); + move |_| auto_complete.is_selected(&value) + }); + let id = uuid::Uuid::new_v4().to_string(); + auto_complete.insert_option(id.clone(), value.clone()); + { + let id = id.clone(); + on_cleanup(move || { + auto_complete.remove_option(&id); + }); + } view! { {children()} diff --git a/thaw/src/auto_complete/mod.rs b/thaw/src/auto_complete/mod.rs index 8f16449..0cac064 100644 --- a/thaw/src/auto_complete/mod.rs +++ b/thaw/src/auto_complete/mod.rs @@ -2,9 +2,14 @@ mod auto_complete_option; pub use auto_complete_option::AutoCompleteOption; -use crate::{ComponentRef, ConfigInjection, Input, InputPrefix, InputRef, InputSuffix}; +use crate::{ + combobox::listbox::{listbox_keyboard_event, Listbox}, + ComponentRef, Input, InputPrefix, InputRef, InputSuffix, + _aria::use_active_descendant, +}; use leptos::{context::Provider, either::Either, html, prelude::*}; -use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement, FollowerWidth}; +use std::collections::HashMap; +use thaw_components::{Binder, Follower, FollowerPlacement, FollowerWidth}; use thaw_utils::{class_list, mount_style, BoxOneCallback, Model, OptionalProp}; #[slot] @@ -25,8 +30,6 @@ pub fn AutoComplete( #[prop(optional, into)] blur_after_select: MaybeSignal, #[prop(optional, into)] on_select: Option>, #[prop(optional, into)] disabled: MaybeSignal, - #[prop(optional, into)] allow_free_input: bool, - #[prop(optional, into)] invalid: MaybeSignal, #[prop(optional, into)] class: OptionalProp>, #[prop(optional)] auto_complete_prefix: Option, #[prop(optional)] auto_complete_suffix: Option, @@ -34,27 +37,20 @@ pub fn AutoComplete( #[prop(optional)] children: Option, ) -> impl IntoView { mount_style("auto-complete", include_str!("./auto-complete.css")); - let config_provider = ConfigInjection::use_(); let input_ref = ComponentRef::::new(); - - let default_index = if allow_free_input { None } else { Some(0) }; - - let select_option_index = RwSignal::>::new(default_index); - let menu_ref = NodeRef::::new(); - let is_show_menu = RwSignal::new(false); + let listbox_ref = NodeRef::::new(); let auto_complete_ref = NodeRef::::new(); - let open_menu = move || { - select_option_index.set(default_index); - is_show_menu.set(true); - }; + let open_listbox = RwSignal::new(false); + let options = StoredValue::new(HashMap::::new()); + let allow_value = move |_| { - if !is_show_menu.get_untracked() { - open_menu(); + if !open_listbox.get_untracked() { + open_listbox.set(true); } true }; - let select_value = Callback::new(move |option_value: String| { + let select_option = Callback::new(move |option_value: String| { if clear_after_select.get_untracked() { value.set(String::new()); } else { @@ -63,10 +59,8 @@ pub fn AutoComplete( if let Some(on_select) = on_select.as_ref() { on_select(option_value); } - if allow_free_input { - select_option_index.set(None); - } - is_show_menu.set(false); + + open_listbox.set(false); if blur_after_select.get_untracked() { if let Some(input_ref) = input_ref.get_untracked() { input_ref.blur(); @@ -74,153 +68,118 @@ pub fn AutoComplete( } }); - // we unset selection index whenever options get changed - // otherwise e.g. selection could move from one item to - // another staying on the same index - //create_effect(move |_| { - //options.track(); - //select_option_index.set(default_index); - //}); + let (set_listbox, active_descendant_controller) = + use_active_descendant(move |el| el.class_list().contains("thaw-auto-complete-option")); + let on_blur = { + let active_descendant_controller = active_descendant_controller.clone(); + move |_| { + active_descendant_controller.blur(); + open_listbox.set(false); + } + }; + let on_keydown = move |e| { + listbox_keyboard_event( + e, + open_listbox, + false, + &active_descendant_controller, + move |option| { + options.with_value(|options| { + if let Some(value) = options.get(&option.id()) { + select_option.call(value.clone()); + } + }); + }, + ); + }; - //let on_keydown = move |event: ev::KeyboardEvent| { - //if !is_show_menu.get_untracked() { - //return; - //} - //let key = event.key(); - //if key == *"ArrowDown" { - //select_option_index.update(|index| { - //if *index == Some(options.with_untracked(|options| options.len()) - 1) { - //*index = default_index - //} else { - //*index = Some(index.map_or(0, |index| index + 1)) - //} - //}); - //} else if key == *"ArrowUp" { - //select_option_index.update(|index| { - //match *index { - //None => *index = Some(options.with_untracked(|options| options.len()) - 1), - //Some(0) => { - //if allow_free_input { - //*index = None - //} else { - //*index = Some(options.with_untracked(|options| options.len()) - 1) - //} - //} - //Some(prev_index) => *index = Some(prev_index - 1), - //}; - //}); - //} else if key == *"Enter" { - //event.prevent_default(); - //let option_value = options.with_untracked(|options| { - //let index = select_option_index.get_untracked(); - //match index { - //None if allow_free_input => { - //let value = value.get_untracked(); - //(!value.is_empty()).then_some(value) - //} - //Some(index) if options.len() > index => { - //let option = &options[index]; - //Some(option.value.clone()) - //} - //_ => None, - //} - //}); - //if let Some(option_value) = option_value { - //select_value(option_value); - //} - //} - //}; comp_ref.load(AutoCompleteRef { input_ref }); - // input_ref.on_load(move |_| { - // }); view! { - - + + - - + - {if let Some(auto_complete_prefix) = auto_complete_prefix { - Some((auto_complete_prefix.children)()) + {if let Some(auto_complete_prefix) = auto_complete_prefix { + Some((auto_complete_prefix.children)()) + } else { + None + }} + + + + + {if let Some(auto_complete_suffix) = auto_complete_suffix { + Some((auto_complete_suffix.children)()) + } else { + None + }} + + + + + + + + { + if let Some(children) = children { + Either::Left(children()) } else { - None - }} - - - - - {if let Some(auto_complete_suffix) = auto_complete_suffix { - Some((auto_complete_suffix.children)()) - } else { - None - }} - - - - - - - - - { - if let Some(children) = children { - Either::Left(children()) - } else { - Either::Right(()) - } - } - - - - - - } + Either::Right(()) + } + } + + + + + } } #[derive(Clone, Copy)] -pub(crate) struct AutoCompleteInjection(pub Model, pub Callback); +pub(crate) struct AutoCompleteInjection { + value: Model, + select_option: Callback, + options: StoredValue>, +} impl AutoCompleteInjection { - pub fn use_() -> Self { + pub fn expect_context() -> Self { expect_context() } pub fn is_selected(&self, key: &String) -> bool { - self.0.with(|value| value == key) + self.value.with(|value| value == key) } - pub fn select_value(&self, key: String) { - self.1.call(key.clone()); + pub fn select_option(&self, value: String) { + self.select_option.call(value); + } + + pub fn insert_option(&self, id: String, value: String) { + self.options + .update_value(|options| options.insert(id, value)); + } + + pub fn remove_option(&self, id: &String) { + self.options.update_value(|options| options.remove(id)); } } diff --git a/thaw/src/combobox/combobox_option.rs b/thaw/src/combobox/combobox_option.rs index c8732f8..5b4a518 100644 --- a/thaw/src/combobox/combobox_option.rs +++ b/thaw/src/combobox/combobox_option.rs @@ -12,7 +12,9 @@ pub fn ComboboxOption( let combobox = ComboboxInjection::expect_context(); let value = StoredValue::new(value.unwrap_or_else(|| text.clone())); let text = StoredValue::new(text); + let is_selected = Memo::new(move |_| value.with_value(|value| combobox.is_selected(&value))); let id = uuid::Uuid::new_v4().to_string(); + let on_click = move |_| { text.with_value(|text| { value.with_value(|value| { @@ -32,11 +34,11 @@ pub fn ComboboxOption( view! { @@ -44,7 +46,7 @@ pub fn ComboboxOption( if combobox.multiselect { view! { - + diff --git a/thaw/src/combobox/mod.rs b/thaw/src/combobox/mod.rs index f295bde..a69f1a5 100644 --- a/thaw/src/combobox/mod.rs +++ b/thaw/src/combobox/mod.rs @@ -1,6 +1,6 @@ mod combobox; mod combobox_option; -mod listbox; +pub(crate) mod listbox; mod utils; pub use combobox::*; diff --git a/thaw/src/input/input.css b/thaw/src/input/input.css index aa9ae1d..7463364 100644 --- a/thaw/src/input/input.css +++ b/thaw/src/input/input.css @@ -122,7 +122,3 @@ .thaw-input--disabled > .thaw-input__input::placeholder { color: var(--colorNeutralForegroundDisabled); } - -.thaw-input--invalid { - border-color: var(--thaw-border-color-error); -} diff --git a/thaw/src/input/mod.rs b/thaw/src/input/mod.rs index 19b3709..636534f 100644 --- a/thaw/src/input/mod.rs +++ b/thaw/src/input/mod.rs @@ -40,7 +40,6 @@ pub fn Input( #[prop(optional, into)] on_focus: Option>, #[prop(optional, into)] on_blur: Option>, #[prop(optional, into)] disabled: MaybeSignal, - #[prop(optional, into)] invalid: MaybeSignal, #[prop(optional)] input_prefix: Option, #[prop(optional)] input_suffix: Option, #[prop(optional)] comp_ref: ComponentRef, @@ -125,8 +124,8 @@ pub fn Input( "thaw-input", ("thaw-input--prefix", prefix_if_), ("thaw-input--suffix", suffix_if_), - ("thaw-input--disabled", move || disabled.get()), ("thaw-input--invalid", move || - invalid.get()), class.map(| c | move || c.get()) + ("thaw-input--disabled", move || disabled.get()), + class.map(| c | move || c.get()) ] on:mousedown=on_mousedown diff --git a/thaw_utils/src/dom/mod.rs b/thaw_utils/src/dom/mod.rs index c5e3111..df361b7 100644 --- a/thaw_utils/src/dom/mod.rs +++ b/thaw_utils/src/dom/mod.rs @@ -1,5 +1,7 @@ mod get_scroll_parent; mod mount_style; +mod scroll_into_view; pub use get_scroll_parent::get_scroll_parent; pub use mount_style::{mount_dynamic_style, mount_style}; +pub use scroll_into_view::scroll_into_view; diff --git a/thaw_utils/src/dom/scroll_into_view.rs b/thaw_utils/src/dom/scroll_into_view.rs new file mode 100644 index 0000000..4e72bed --- /dev/null +++ b/thaw_utils/src/dom/scroll_into_view.rs @@ -0,0 +1,14 @@ +use super::get_scroll_parent; +use web_sys::HtmlElement; + +pub fn scroll_into_view(el: &HtmlElement) { + if let Some(parent) = get_scroll_parent(el) { + let parent_rect = parent.get_bounding_client_rect(); + let el_rect = el.get_bounding_client_rect(); + if el_rect.y() < parent_rect.y() { + el.scroll_into_view_with_bool(true); + } else if el_rect.y() + el_rect.height() > parent_rect.y() + parent_rect.height() { + el.scroll_into_view_with_bool(false); + } + } +}