diff --git a/thaw/Cargo.toml b/thaw/Cargo.toml index aa09cea..0bf4feb 100644 --- a/thaw/Cargo.toml +++ b/thaw/Cargo.toml @@ -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" diff --git a/thaw/src/_aria/active_descendant/mod.rs b/thaw/src/_aria/active_descendant/mod.rs new file mode 100644 index 0000000..a417097 --- /dev/null +++ b/thaw/src/_aria/active_descendant/mod.rs @@ -0,0 +1,4 @@ +mod use_active_descendant; +mod use_option_walker; + +pub use use_active_descendant::use_active_descendant; diff --git a/thaw/src/_aria/active_descendant/use_active_descendant.rs b/thaw/src/_aria/active_descendant/use_active_descendant.rs new file mode 100644 index 0000000..c17c90a --- /dev/null +++ b/thaw/src/_aria/active_descendant/use_active_descendant.rs @@ -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( + match_option: MF, +) -> (Arc, 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>>>, + // last_active: RefCell>, +} + +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 { + let active = self.active.borrow(); + if let Some(active) = active.as_ref() { + Some(active.clone()) + } else { + None + } + } +} diff --git a/thaw/src/_aria/active_descendant/use_option_walker.rs b/thaw/src/_aria/active_descendant/use_option_walker.rs new file mode 100644 index 0000000..315e8a7 --- /dev/null +++ b/thaw/src/_aria/active_descendant/use_option_walker.rs @@ -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(match_option: MF) -> (Box, OptionWalker) +where + MF: Fn(HtmlElement) -> bool + 'static, +{ + let tree_walker = StoredValue::new( + None::<( + SendWrapper, + SendWrapper 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 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, + SendWrapper u32>>, + )>, + >, +); + +impl OptionWalker { + pub fn first(&self) -> Option { + 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 { + 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 { + 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 { + 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() + }) + } +} diff --git a/thaw/src/_aria/mod.rs b/thaw/src/_aria/mod.rs new file mode 100644 index 0000000..82d3475 --- /dev/null +++ b/thaw/src/_aria/mod.rs @@ -0,0 +1,3 @@ +mod active_descendant; + +pub use active_descendant::*; diff --git a/thaw/src/combobox/combobox.css b/thaw/src/combobox/combobox.css index 3fd11e9..8419bee 100644 --- a/thaw/src/combobox/combobox.css +++ b/thaw/src/combobox/combobox.css @@ -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); diff --git a/thaw/src/combobox/combobox.rs b/thaw/src/combobox/combobox.rs index b54760a..c44e7d3 100644 --- a/thaw/src/combobox/combobox.rs +++ b/thaw/src/combobox/combobox.rs @@ -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::::new(); + let input_ref = NodeRef::::new(); let listbox_ref = NodeRef::::new(); let is_show_listbox = RwSignal::new(false); + let options = StoredValue::new(HashMap::::new()); let clear_icon_ref = NodeRef::::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! {
{ 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(); + } + } >
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 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", + } + } +} diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 3bdf597..60a1942 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -1,3 +1,4 @@ +mod _aria; mod accordion; mod anchor; mod auto_complete; diff --git a/thaw/src/theme/color.rs b/thaw/src/theme/color.rs index 75253a6..128f198 100644 --- a/thaw/src/theme/color.rs +++ b/thaw/src/theme/color.rs @@ -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(),