feat: Combobox adds keyboard operations

This commit is contained in:
luoxiao 2024-07-21 22:32:34 +08:00
parent c186c8b352
commit 900b949fa2
12 changed files with 393 additions and 14 deletions

View file

@ -24,6 +24,8 @@ web-sys = { version = "0.3.69", features = [
"DataTransfer",
"ScrollToOptions",
"ScrollBehavior",
"TreeWalker",
"NodeFilter",
] }
wasm-bindgen = "0.2.92"
icondata_core = "0.1.0"

View file

@ -0,0 +1,4 @@
mod use_active_descendant;
mod use_option_walker;
pub use use_active_descendant::use_active_descendant;

View file

@ -0,0 +1,97 @@
use super::use_option_walker::{use_option_walker, OptionWalker};
use send_wrapper::SendWrapper;
use std::{cell::RefCell, sync::Arc};
use web_sys::{HtmlElement, Node};
/// Applied to the element that is active descendant
const ACTIVEDESCENDANT_ATTRIBUTE: &str = "data-activedescendant";
/// Applied to the active descendant when the user is navigating with keyboard
const ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE: &str = "data-activedescendant-focusvisible";
pub fn use_active_descendant<MF>(
match_option: MF,
) -> (Arc<dyn Fn(Node)>, ActiveDescendantController)
where
MF: Fn(HtmlElement) -> bool + 'static,
{
let (set_listbox, option_walker) = use_option_walker(match_option);
let set_listbox = Arc::new(move |node| {
set_listbox(&node);
});
let controller = ActiveDescendantController {
option_walker,
active: Arc::new(SendWrapper::new(Default::default())),
// last_active: Default::default(),
};
(set_listbox, controller)
}
#[derive(Clone)]
pub struct ActiveDescendantController {
option_walker: OptionWalker,
active: Arc<SendWrapper<RefCell<Option<HtmlElement>>>>,
// last_active: RefCell<Option<HtmlElement>>,
}
impl ActiveDescendantController {
fn blur_active_descendant(&self) {
let mut active = self.active.borrow_mut();
let Some(active_el) = active.as_mut() else {
return;
};
let _ = active_el.remove_attribute(ACTIVEDESCENDANT_ATTRIBUTE);
let _ = active_el.remove_attribute(ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE);
*active = None;
}
fn focus_active_descendant(&self, next_active: HtmlElement) {
self.blur_active_descendant();
let _ = next_active.set_attribute(ACTIVEDESCENDANT_ATTRIBUTE, "");
let _ = next_active.set_attribute(ACTIVEDESCENDANT_FOCUSVISIBLE_ATTRIBUTE, "");
*self.active.borrow_mut() = Some(next_active);
}
}
impl ActiveDescendantController {
pub fn first(&self) {
if let Some(first) = self.option_walker.first() {
self.focus_active_descendant(first);
}
}
pub fn last(&self) {
if let Some(last) = self.option_walker.last() {
self.focus_active_descendant(last);
}
}
pub fn next(&self) {
if let Some(next) = self.option_walker.next() {
self.focus_active_descendant(next);
}
}
pub fn prev(&self) {
if let Some(prev) = self.option_walker.prev() {
self.focus_active_descendant(prev);
}
}
pub fn blur(&self) {
self.blur_active_descendant();
}
pub fn active(&self) -> Option<HtmlElement> {
let active = self.active.borrow();
if let Some(active) = active.as_ref() {
Some(active.clone())
} else {
None
}
}
}

View file

@ -0,0 +1,90 @@
use leptos::{prelude::document, reactive_graph::owner::StoredValue};
use send_wrapper::SendWrapper;
use std::sync::Arc;
use wasm_bindgen::{closure::Closure, JsCast, UnwrapThrowExt};
use web_sys::{HtmlElement, Node, NodeFilter, TreeWalker};
pub fn use_option_walker<MF>(match_option: MF) -> (Box<dyn Fn(&Node)>, OptionWalker)
where
MF: Fn(HtmlElement) -> bool + 'static,
{
let tree_walker = StoredValue::new(
None::<(
SendWrapper<TreeWalker>,
SendWrapper<Closure<dyn Fn(Node) -> u32>>,
)>,
);
let option_walker = OptionWalker(tree_walker);
let match_option = Arc::new(match_option);
let set_listbox = move |el: &Node| {
let match_option = match_option.clone();
let cb: Closure<dyn Fn(Node) -> u32> = Closure::new(move |node: Node| {
if let Ok(html_element) = node.dyn_into() {
if match_option(html_element) {
return 1u32;
}
}
3u32
});
let mut node_filter = NodeFilter::new();
node_filter.accept_node(cb.as_ref().unchecked_ref());
let tw = document()
.create_tree_walker_with_what_to_show_and_filter(el, 0x1, Some(&node_filter))
.unwrap_throw();
tree_walker.set_value(Some((SendWrapper::new(tw), SendWrapper::new(cb))));
};
(Box::new(set_listbox), option_walker)
}
#[derive(Clone)]
pub struct OptionWalker(
StoredValue<
Option<(
SendWrapper<TreeWalker>,
SendWrapper<Closure<dyn Fn(Node) -> u32>>,
)>,
>,
);
impl OptionWalker {
pub fn first(&self) -> Option<HtmlElement> {
self.0.with_value(|tree_walker| {
let Some((tree_walker, _)) = tree_walker.as_ref() else {
return None;
};
tree_walker.set_current_node(&tree_walker.root());
tree_walker.first_child().unwrap_throw()?.dyn_into().ok()
})
}
pub fn last(&self) -> Option<HtmlElement> {
self.0.with_value(|tree_walker| {
let Some((tree_walker, _)) = tree_walker.as_ref() else {
return None;
};
tree_walker.set_current_node(&tree_walker.root());
tree_walker.last_child().unwrap_throw()?.dyn_into().ok()
})
}
pub fn next(&self) -> Option<HtmlElement> {
self.0.with_value(|tree_walker| {
let Some((tree_walker, _)) = tree_walker.as_ref() else {
return None;
};
tree_walker.next_node().unwrap_throw()?.dyn_into().ok()
})
}
pub fn prev(&self) -> Option<HtmlElement> {
self.0.with_value(|tree_walker| {
let Some((tree_walker, _)) = tree_walker.as_ref() else {
return None;
};
tree_walker.previous_node().unwrap_throw()?.dyn_into().ok()
})
}
}

3
thaw/src/_aria/mod.rs Normal file
View file

@ -0,0 +1,3 @@
mod active_descendant;
pub use active_descendant::*;

View file

@ -141,6 +141,19 @@
border-radius: var(--borderRadiusMedium);
}
.thaw-combobox-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-combobox-option:hover {
color: var(--colorNeutralForeground1Hover);
background-color: var(--colorNeutralBackground1Hover);

View file

@ -1,5 +1,7 @@
use crate::ConfigInjection;
use super::utils::{get_dropdown_action_from_key, DropdownAction};
use crate::{ConfigInjection, _aria::use_active_descendant};
use leptos::{context::Provider, ev, html, prelude::*};
use std::collections::HashMap;
use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement, FollowerWidth};
use thaw_utils::{add_event_listener, mount_style, Model};
@ -14,8 +16,10 @@ pub fn Combobox(
mount_style("combobox", include_str!("./combobox.css"));
let config_provider = ConfigInjection::use_();
let trigger_ref = NodeRef::<html::Div>::new();
let input_ref = NodeRef::<html::Input>::new();
let listbox_ref = NodeRef::<html::Div>::new();
let is_show_listbox = RwSignal::new(false);
let options = StoredValue::new(HashMap::<String, (String, String)>::new());
let clear_icon_ref = NodeRef::<html::Span>::new();
let is_show_clear_icon = Memo::new(move |_| {
@ -86,24 +90,83 @@ pub fn Combobox(
}
value.set(input_value);
};
let on_blur = move |_| {
if multiselect {
value.set(String::new());
} else {
if selected_options.with_untracked(|options| options.is_empty()) {
let combobox_injection = ComboboxInjection {
value,
multiselect,
selected_options,
options,
is_show_listbox,
};
let (set_listbox, active_descendant_controller) =
use_active_descendant(move |el| el.class_list().contains("thaw-combobox-option"));
let on_blur = {
let active_descendant_controller = active_descendant_controller.clone();
move |_| {
if multiselect {
value.set(String::new());
} else {
if selected_options.with_untracked(|options| options.is_empty()) {
value.set(String::new());
}
}
active_descendant_controller.blur();
}
};
let effect = RenderEffect::new(move |_| {
if let Some(listbox_el) = listbox_ref.get() {
set_listbox(listbox_el.into());
}
});
on_cleanup(move || {
drop(effect);
});
let on_keydown = move |e| {
let open = is_show_listbox.get_untracked();
let action = get_dropdown_action_from_key(e, open, multiselect);
let active_option = active_descendant_controller.active();
match action {
DropdownAction::Type | DropdownAction::Open => {
if !open {
is_show_listbox.set(true);
}
}
DropdownAction::CloseSelect | DropdownAction::Close => {
if let Some(option) = active_option {
combobox_injection.options.with_value(|options| {
if let Some((value, text)) = options.get(&option.id()) {
combobox_injection.select_option(value, text);
}
});
}
}
DropdownAction::Next => {
if active_option.is_some() {
active_descendant_controller.next();
} else {
active_descendant_controller.first();
}
}
DropdownAction::Previous => {
if active_option.is_some() {
active_descendant_controller.prev();
} else {
active_descendant_controller.first();
}
}
DropdownAction::None => {}
};
};
view! {
<Binder target_ref=trigger_ref>
<div
class="thaw-combobox"
node_ref=trigger_ref
on:click=move |_| {
is_show_listbox.update(|show| *show = !*show);
}
>
<input
type="text"
@ -113,8 +176,13 @@ pub fn Combobox(
prop:value=move || {
value.get()
}
node_ref=input_ref
on:input=on_input
on:blur=on_blur
on:keydown=on_keydown
on:click=move |_| {
is_show_listbox.update(|show| *show = !*show);
}
/>
{
if clearable {
@ -140,6 +208,12 @@ pub fn Combobox(
aria-label="Open"
class="thaw-combobox__expand-icon"
style=move || is_show_clear_icon.get().then(|| "display: none").unwrap_or_default()
on:click=move |_| {
is_show_listbox.update(|show| *show = !*show);
if let Some(el) = input_ref.get_untracked() {
let _ = el.focus();
}
}
>
<svg fill="currentColor" aria-hidden="true" width="1em" height="1em" viewBox="0 0 20 20">
<path d="M15.85 7.65c.2.2.2.5 0 .7l-5.46 5.49a.55.55 0 0 1-.78 0L4.15 8.35a.5.5 0 1 1 .7-.7L10 12.8l5.15-5.16c.2-.2.5-.2.7 0Z" fill="currentColor">
@ -153,7 +227,7 @@ pub fn Combobox(
placement=FollowerPlacement::BottomStart
width=FollowerWidth::MinTarget
>
<Provider value=ComboboxInjection{value, multiselect, selected_options, is_show_listbox}>
<Provider value=combobox_injection>
<CSSTransition
node_ref=listbox_ref
name="fade-in-scale-up-transition"
@ -181,21 +255,31 @@ pub fn Combobox(
pub(crate) struct ComboboxInjection {
value: Model<String>,
selected_options: Model<Vec<String>>,
options: StoredValue<HashMap<String, (String, String)>>,
is_show_listbox: RwSignal<bool>,
pub multiselect: bool,
}
impl ComboboxInjection {
pub fn use_() -> Self {
pub fn expect_context() -> Self {
expect_context()
}
pub fn insert_option(&self, id: String, value: (String, 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));
}
pub fn is_selected(&self, value: &String) -> bool {
self.selected_options
.with(|options| options.contains(value))
}
pub fn on_option_select(&self, value: &String, text: &String) {
pub fn select_option(&self, value: &String, text: &String) {
self.selected_options.update(|options| {
if self.multiselect {
if let Some(index) = options.iter().position(|v| v == value) {

View file

@ -9,21 +9,31 @@ pub fn ComboboxOption(
#[prop(into)] text: String,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
let combobox = ComboboxInjection::use_();
let combobox = ComboboxInjection::expect_context();
let value = StoredValue::new(value.unwrap_or_else(|| text.clone()));
let text = StoredValue::new(text);
let id = uuid::Uuid::new_v4().to_string();
let on_click = move |_| {
text.with_value(|text| {
value.with_value(|value| {
combobox.on_option_select(value, text);
combobox.select_option(value, text);
});
});
};
{
combobox.insert_option(id.clone(), (value.get_value(), text.get_value()));
let id = id.clone();
on_cleanup(move || {
combobox.remove_option(&id);
});
}
view! {
<div
role="option"
aria-selected="true"
id=id
class=class_list![
"thaw-combobox-option",
("thaw-combobox-option--selected", move || value.with_value(|value| combobox.is_selected(&value)))

View file

@ -1,5 +1,6 @@
mod combobox;
mod combobox_option;
mod utils;
pub use combobox::*;
pub use combobox_option::*;

View file

@ -0,0 +1,68 @@
use leptos::ev;
pub fn get_dropdown_action_from_key(
e: ev::KeyboardEvent,
open: bool,
multiselect: bool,
) -> DropdownAction {
let key = e.key();
let code = e.code();
let alt_key = e.alt_key();
let ctrl_key = e.ctrl_key();
let meta_key = e.meta_key();
if key.len() == 1 && KeyboardKey::Space != code && !alt_key && !ctrl_key && !meta_key {
DropdownAction::Type
} else if !open {
if KeyboardKey::ArrowDown == code
|| KeyboardKey::ArrowUp == code
|| KeyboardKey::Enter == code
|| KeyboardKey::Space == code
{
DropdownAction::Open
} else {
DropdownAction::None
}
} else if (KeyboardKey::ArrowUp == code && alt_key)
|| KeyboardKey::Enter == code
|| (!multiselect && KeyboardKey::Space == code)
{
DropdownAction::CloseSelect
} else if multiselect && KeyboardKey::Space == code {
DropdownAction::Close
} else if KeyboardKey::ArrowDown == code {
DropdownAction::Next
} else if KeyboardKey::ArrowUp == code {
DropdownAction::Previous
} else {
DropdownAction::None
}
}
pub enum DropdownAction {
None,
Type,
Open,
CloseSelect,
Close,
Next,
Previous,
}
enum KeyboardKey {
ArrowDown,
ArrowUp,
Enter,
Space,
}
impl PartialEq<String> for KeyboardKey {
fn eq(&self, other: &String) -> bool {
match self {
Self::ArrowDown => other == "ArrowDown",
Self::ArrowUp => other == "ArrowUp",
Self::Enter => other == "Enter",
Self::Space => other == "Space",
}
}
}

View file

@ -1,3 +1,4 @@
mod _aria;
mod accordion;
mod anchor;
mod auto_complete;

View file

@ -66,6 +66,8 @@ pub struct ColorTheme {
pub color_brand_stroke_2: String,
pub color_brand_stroke_2_contrast: String,
pub color_stroke_focus_2: String,
pub color_palette_red_background_1: String,
pub color_palette_red_background_3: String,
pub color_palette_red_foreground_1: String,
@ -181,6 +183,8 @@ impl ColorTheme {
color_brand_stroke_2: "#b4d6fa".into(),
color_brand_stroke_2_contrast: "#b4d6fa".into(),
color_stroke_focus_2: "#000000".into(),
color_palette_red_background_1: "#fdf6f6".into(),
color_palette_red_background_3: "#d13438".into(),
color_palette_red_foreground_1: "#bc2f32".into(),
@ -296,6 +300,8 @@ impl ColorTheme {
color_brand_stroke_2: "#0e4775".into(),
color_brand_stroke_2_contrast: "#0e4775".into(),
color_stroke_focus_2: "#ffffff".into(),
color_palette_red_background_1: "#3f1011".into(),
color_palette_red_background_3: "#d13438".into(),
color_palette_red_foreground_1: "#e37d80".into(),