mirror of
https://github.com/adoyle0/thaw.git
synced 2025-01-22 22:09:22 -05:00
feat: Combobox adds keyboard operations
This commit is contained in:
parent
c186c8b352
commit
900b949fa2
12 changed files with 393 additions and 14 deletions
|
@ -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"
|
||||
|
|
4
thaw/src/_aria/active_descendant/mod.rs
Normal file
4
thaw/src/_aria/active_descendant/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod use_active_descendant;
|
||||
mod use_option_walker;
|
||||
|
||||
pub use use_active_descendant::use_active_descendant;
|
97
thaw/src/_aria/active_descendant/use_active_descendant.rs
Normal file
97
thaw/src/_aria/active_descendant/use_active_descendant.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
90
thaw/src/_aria/active_descendant/use_option_walker.rs
Normal file
90
thaw/src/_aria/active_descendant/use_option_walker.rs
Normal 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
3
thaw/src/_aria/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
mod active_descendant;
|
||||
|
||||
pub use active_descendant::*;
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
mod combobox;
|
||||
mod combobox_option;
|
||||
mod utils;
|
||||
|
||||
pub use combobox::*;
|
||||
pub use combobox_option::*;
|
||||
|
|
68
thaw/src/combobox/utils.rs
Normal file
68
thaw/src/combobox/utils.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
mod _aria;
|
||||
mod accordion;
|
||||
mod anchor;
|
||||
mod auto_complete;
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Add table
Reference in a new issue