diff --git a/packages/primitives/leptos/arrow/src/arrow.rs b/packages/primitives/leptos/arrow/src/arrow.rs index 6253c79..4f4cae4 100644 --- a/packages/primitives/leptos/arrow/src/arrow.rs +++ b/packages/primitives/leptos/arrow/src/arrow.rs @@ -24,6 +24,7 @@ pub fn Arrow( view! { + + + +

+ +

radix-leptos-focus-group

+ +This is an internal utility, not intended for public usage. + +## Rust Radix + +[Rust Radix](https://github.com/NixySoftware/radix) is a Rust port of [Radix](https://www.radix-ui.com/primitives). diff --git a/packages/primitives/leptos/focus-scope/src/focus_scope.rs b/packages/primitives/leptos/focus-scope/src/focus_scope.rs new file mode 100644 index 0000000..8c81eb0 --- /dev/null +++ b/packages/primitives/leptos/focus-scope/src/focus_scope.rs @@ -0,0 +1,246 @@ +// TODO: remove +#![allow(unused)] + +use std::ops::Deref; + +use leptos::{html::AnyElement, *}; +use radix_leptos_primitive::Primitive; +use web_sys::{ + wasm_bindgen::{closure::Closure, JsCast}, + NodeFilter, +}; + +struct FocusScopeValue { + pub paused: bool, +} + +impl FocusScopeValue { + pub fn new() -> Self { + Self { paused: false } + } + + pub fn pause(&mut self) { + self.paused = true; + } + + pub fn resume(&mut self) { + self.paused = false; + } +} + +#[component] +pub fn FocusScope( + #[prop(into, optional)] r#loop: MaybeProp, + #[prop(into, optional)] trapped: MaybeProp, + // TODO: event handlers + #[prop(into, optional)] as_child: MaybeProp, + #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>, + children: ChildrenFn, +) -> impl IntoView { + // let r#loop = move || r#loop.get().unwrap_or(false); + let trapped = move || trapped.get().unwrap_or(false); + + // let container_ref = create_node_ref::(); + // let last_focused_element = create_signal::>>(None); + + // let focus_scope = create_rw_signal(FocusScopeValue::new()); + + create_effect(move |_| { + if trapped() { + // TODO + } + }); + + let mut attrs = attrs.clone(); + attrs.extend(vec![("tabindex", "-1".into_attribute())]); + + view! { + + {children()} + + } +} + +#[derive(Clone, Debug, Default)] +struct FocusOptions { + pub select: bool, +} + +/// Attempts focusing the first element in a list of candidates. +/// Stops when focus has actually moved. +fn focus_first(candidates: Vec, options: Option) { + let previously_focused_element = document().active_element(); + + for candidate in candidates { + focus(Some(candidate), options.clone()); + if document().active_element() != previously_focused_element { + return; + } + } +} + +/// Returns the first and last tabbable elements inside a container. +fn get_tabbable_edges( + container: &web_sys::HtmlElement, +) -> (Option, Option) { + let candidates = get_tabbable_candidates(container); + + let mut reverse_candidates = candidates.clone(); + reverse_candidates.reverse(); + + let first = find_visible(candidates, container); + let last = find_visible(reverse_candidates, container); + + (first, last) +} + +/// Returns a list of potential tabbable candidates. +/// +/// NOTE: This is only a close approximation. For example it doesn't take into account cases like when +/// elements are not visible. This cannot be worked out easily by just reading a property, but rather +/// necessitate runtime knowledge (computed styles, etc). We deal with these cases separately. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker +/// Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 +fn get_tabbable_candidates(container: &web_sys::HtmlElement) -> Vec { + let mut nodes: Vec = vec![]; + + let accept_node_closure: Closure u32> = + Closure::new(move |node: web_sys::Node| -> u32 { + if let Some(html_element) = node.dyn_ref::() { + if html_element.hidden() { + // NodeFilter.FILTER_SKIP + return 3; + } + + if let Some(input_element) = node.dyn_ref::() { + if input_element.disabled() || input_element.type_() == "hidden" { + // NodeFilter.FILTER_SKIP + return 3; + } + } + + if html_element.tab_index() >= 0 { + // NodeFilter.FILTER_ACCEPT + return 1; + } + } + + // NodeFilter.FILTER_SKIP + 3 + }); + + let mut node_filter = NodeFilter::new(); + node_filter.accept_node(accept_node_closure.as_ref().unchecked_ref()); + + let walker = document() + // 0x01 is NodeFilter.SHOW_ELEMENT + .create_tree_walker_with_what_to_show_and_filter(container, 0x1, Some(&node_filter)) + .expect("Tree walker should be created."); + + while let Some(node) = walker + .next_node() + .expect("Tree walker should return a next node.") + { + if let Ok(element) = node.dyn_into::() { + nodes.push(element); + } + } + + // We do not take into account the order of nodes with positive `tabindex` as it + // hinders accessibility to have tab order different from visual order. + nodes +} + +/// Returns the first visible element in a list. +/// NOTE: Only checks visibility up to the `container`. +fn find_visible( + elements: Vec, + container: &web_sys::HtmlElement, +) -> Option { + elements.into_iter().find(|element| { + !is_hidden( + element, + Some(IsHiddenOptions { + up_to: Some(container), + }), + ) + }) +} + +#[derive(Debug, Default, Clone)] +struct IsHiddenOptions<'a> { + pub up_to: Option<&'a web_sys::HtmlElement>, +} + +fn is_hidden(node: &web_sys::HtmlElement, options: Option) -> bool { + let options = options.unwrap_or_default(); + + if window() + .get_computed_style(node) + .expect("Element is valid.") + .expect("Element should have computed style.") + .get_property_value("visibility") + .expect("Computed style should have visibility.") + == "hidden" + { + return true; + } + + let mut node: Option = Some(node.deref().clone()); + while let Some(n) = node.as_ref() { + if let Some(up_to) = options.up_to.as_ref() { + // We stop at `upTo` (excluding it). + let up_to_element: &web_sys::Element = up_to; + if n == up_to_element { + return false; + } + + if window() + .get_computed_style(n) + .expect("Element is valid.") + .expect("Element should have computed style.") + .get_property_value("visibility") + .expect("Computed style should have display.") + == "none" + { + return true; + } + + node = n.parent_element(); + } + } + + false +} + +fn is_selectable_input(element: &web_sys::Element) -> bool { + web_sys::HtmlInputElement::instanceof(element) +} + +fn focus(element: Option, options: Option) { + let options = options.unwrap_or_default(); + + if let Some(element) = element { + let previously_focused_element = document().active_element(); + + // NOTE: We prevent scrolling on focus, to minimize jarring transitions for users. + // TODO: web_sys does not support passing options. JS: element.focus({ preventScroll: true }) + element.focus().expect("Focus should be successful."); + + // Only select if its not the same element, it supports selection and we need to select. + let el: &web_sys::Element = &element; + if Some(el) != previously_focused_element.as_ref() + && is_selectable_input(el) + && options.select + { + element + .unchecked_into::() + .select(); + } + } +} diff --git a/packages/primitives/leptos/focus-scope/src/lib.rs b/packages/primitives/leptos/focus-scope/src/lib.rs new file mode 100644 index 0000000..d7f09a2 --- /dev/null +++ b/packages/primitives/leptos/focus-scope/src/lib.rs @@ -0,0 +1,9 @@ +//! Leptos port of [Radix Focus Scope](https://www.radix-ui.com/primitives). +//! +//! This is an internal utility, not intended for public usage. +//! +//! See [`@radix-ui/react-focus-scope`](https://www.npmjs.com/package/@radix-ui/react-focus-scope) for the original package. + +mod focus_scope; + +pub use focus_scope::*;