feat: AutoComplete adds keyboard operations

This commit is contained in:
luoxiao 2024-07-22 16:28:38 +08:00
parent 0a8e98576a
commit 87a4ef115c
13 changed files with 167 additions and 216 deletions

View file

@ -150,7 +150,7 @@ pub fn SiteHeader() -> impl IntoView {
comp_ref=auto_complete_ref
>
<For each=move || search_options.get() key=|option| option.label.clone() let:option>
<AutoCompleteOption key=option.value>{option.label}</AutoCompleteOption>
<AutoCompleteOption value=option.value>{option.label}</AutoCompleteOption>
</For>
<AutoCompletePrefix slot>
<Icon

View file

@ -20,7 +20,7 @@ view! {
key=|option| option.0.clone()
let:option
>
<AutoCompleteOption key=option.0>
<AutoCompleteOption value=option.0>
{option.1}
</AutoCompleteOption>
</For>
@ -36,14 +36,6 @@ view! {
}
```
### Invalid
```rust demo
view! {
<AutoComplete placeholder="Email" invalid=true/>
}
```
### Prefix & Suffix
```rust demo

View file

@ -54,16 +54,6 @@ view! {
}
```
### Invalid
```rust demo
let value = RwSignal::new(String::from("o"));
view! {
<Input value invalid=true/>
}
```
### Imperative handle
```rust demo

View file

@ -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, "");

View file

@ -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);

View file

@ -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! {
<div
class="thaw-auto-complete-option"
role="option"
id=id
aria-selected=move || if is_selected.get() { "true" } else { "false" }
on:click=move |_| auto_complete.select_value(value.clone())
on:click=move |_| auto_complete.select_option(value.clone())
>
{children()}
</div>

View file

@ -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<bool>,
#[prop(optional, into)] on_select: Option<BoxOneCallback<String>>,
#[prop(optional, into)] disabled: MaybeSignal<bool>,
#[prop(optional, into)] allow_free_input: bool,
#[prop(optional, into)] invalid: MaybeSignal<bool>,
#[prop(optional, into)] class: OptionalProp<MaybeSignal<String>>,
#[prop(optional)] auto_complete_prefix: Option<AutoCompletePrefix>,
#[prop(optional)] auto_complete_suffix: Option<AutoCompleteSuffix>,
@ -34,27 +37,20 @@ pub fn AutoComplete(
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
mount_style("auto-complete", include_str!("./auto-complete.css"));
let config_provider = ConfigInjection::use_();
let input_ref = ComponentRef::<InputRef>::new();
let default_index = if allow_free_input { None } else { Some(0) };
let select_option_index = RwSignal::<Option<usize>>::new(default_index);
let menu_ref = NodeRef::<html::Div>::new();
let is_show_menu = RwSignal::new(false);
let listbox_ref = NodeRef::<html::Div>::new();
let auto_complete_ref = NodeRef::<html::Div>::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::<String, String>::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! {
<Binder target_ref=auto_complete_ref>
<div
class=class_list!["thaw-auto-complete", class.map(| c | move || c.get())]
node_ref=auto_complete_ref
// on:keydown=on_keydown
<Binder target_ref=auto_complete_ref>
<div
class=class_list!["thaw-auto-complete", class.map(| c | move || c.get())]
node_ref=auto_complete_ref
on:keydown=on_keydown
>
<Input
value
placeholder
disabled
on_focus=move |_| open_listbox.set(true)
on_blur=on_blur
allow_value
comp_ref=input_ref
>
<Input
value
placeholder
disabled
invalid
on_focus=move |_| open_menu()
on_blur=move |_| is_show_menu.set(false)
allow_value
comp_ref=input_ref
>
<InputPrefix if_=auto_complete_prefix.is_some() slot>
<InputPrefix if_=auto_complete_prefix.is_some() slot>
{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
}}
</InputPrefix>
<InputSuffix if_=auto_complete_suffix.is_some() slot>
{if let Some(auto_complete_suffix) = auto_complete_suffix {
Some((auto_complete_suffix.children)())
} else {
None
}}
</InputSuffix>
</Input>
</div>
<Follower
slot
show=open_listbox
placement=FollowerPlacement::BottomStart
width=FollowerWidth::Target
>
<Provider value=AutoCompleteInjection{value, select_option, options}>
<Listbox open=open_listbox.read_only() set_listbox listbox_ref class="thaw-auto-complete__listbox">
{
if let Some(children) = children {
Either::Left(children())
} else {
None
}}
</InputPrefix>
<InputSuffix if_=auto_complete_suffix.is_some() slot>
{if let Some(auto_complete_suffix) = auto_complete_suffix {
Some((auto_complete_suffix.children)())
} else {
None
}}
</InputSuffix>
</Input>
</div>
<Follower
slot
show=is_show_menu
placement=FollowerPlacement::BottomStart
width=FollowerWidth::Target
>
<Provider value=AutoCompleteInjection(value, select_value)>
<CSSTransition
node_ref=menu_ref
name="fade-in-scale-up-transition"
appear=is_show_menu.get_untracked()
show=is_show_menu
let:display
>
<div
class="thaw-config-provider thaw-auto-complete__listbox"
style=move || display.get().unwrap_or_default()
data-thaw-id=config_provider.id().clone()
node_ref=menu_ref
role="listbox"
>
{
if let Some(children) = children {
Either::Left(children())
} else {
Either::Right(())
}
}
</div>
</CSSTransition>
</Provider>
</Follower>
</Binder>
}
Either::Right(())
}
}
</Listbox>
</Provider>
</Follower>
</Binder>
}
}
#[derive(Clone, Copy)]
pub(crate) struct AutoCompleteInjection(pub Model<String>, pub Callback<String>);
pub(crate) struct AutoCompleteInjection {
value: Model<String>,
select_option: Callback<String>,
options: StoredValue<HashMap<String, String>>,
}
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));
}
}

View file

@ -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! {
<div
role="option"
aria-selected="true"
aria-selected=move || if is_selected.get() { "true" } else { "false" }
id=id
class=class_list![
"thaw-combobox-option",
("thaw-combobox-option--selected", move || value.with_value(|value| combobox.is_selected(&value)))
("thaw-combobox-option--selected", move || is_selected.get())
]
on:click=on_click
>
@ -44,7 +46,7 @@ pub fn ComboboxOption(
if combobox.multiselect {
view! {
<span aria-hidden="true" class="thaw-combobox-option__check-icon--multiselect">
<If cond=Signal::derive(move || value.with_value(|value| combobox.is_selected(&value)))>
<If cond=is_selected>
<Then slot>
<svg fill="currentColor" aria-hidden="true" width="12" height="12" viewBox="0 0 12 12">
<path d="M9.76 3.2c.3.29.32.76.04 1.06l-4.25 4.5a.75.75 0 0 1-1.08.02L2.22 6.53a.75.75 0 0 1 1.06-1.06l1.7 1.7L8.7 3.24a.75.75 0 0 1 1.06-.04Z" fill="currentColor"></path>

View file

@ -1,6 +1,6 @@
mod combobox;
mod combobox_option;
mod listbox;
pub(crate) mod listbox;
mod utils;
pub use combobox::*;

View file

@ -122,7 +122,3 @@
.thaw-input--disabled > .thaw-input__input::placeholder {
color: var(--colorNeutralForegroundDisabled);
}
.thaw-input--invalid {
border-color: var(--thaw-border-color-error);
}

View file

@ -40,7 +40,6 @@ pub fn Input(
#[prop(optional, into)] on_focus: Option<BoxOneCallback<ev::FocusEvent>>,
#[prop(optional, into)] on_blur: Option<BoxOneCallback<ev::FocusEvent>>,
#[prop(optional, into)] disabled: MaybeSignal<bool>,
#[prop(optional, into)] invalid: MaybeSignal<bool>,
#[prop(optional)] input_prefix: Option<InputPrefix>,
#[prop(optional)] input_suffix: Option<InputSuffix>,
#[prop(optional)] comp_ref: ComponentRef<InputRef>,
@ -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

View file

@ -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;

View file

@ -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);
}
}
}