Skip to content

Commit

Permalink
Add start of focus-scope
Browse files Browse the repository at this point in the history
  • Loading branch information
DanielleHuisman committed Jun 16, 2024
1 parent 5741c85 commit 81a7a69
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/primitives/leptos/arrow/src/arrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub fn Arrow(
view! {
<Primitive
element=svg::svg
as_child=as_child
attrs=attrs
>
<Show
Expand Down
14 changes: 14 additions & 0 deletions packages/primitives/leptos/focus-scope/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "radix-leptos-focus-scope"
description = "Leptos port of Radix Focus Scope."

authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
version.workspace = true

[dependencies]
leptos.workspace = true
radix-leptos-primitive = { path = "../primitive", version = "0.0.1" }
web-sys = { workspace = true, features = ["NodeFilter"] }
13 changes: 13 additions & 0 deletions packages/primitives/leptos/focus-scope/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<p align="center">
<a href="../../../../logo.svg" alt="Rust Radix logo">
<img src="../../../../logo.svg" width="300" height="200">
</a>
</p>

<h1 align="center">radix-leptos-focus-group</h1>

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).
246 changes: 246 additions & 0 deletions packages/primitives/leptos/focus-scope/src/focus_scope.rs
Original file line number Diff line number Diff line change
@@ -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<bool>,
#[prop(into, optional)] trapped: MaybeProp<bool>,
// TODO: event handlers
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[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::<AnyElement>();
// let last_focused_element = create_signal::<Option<HtmlElement<AnyElement>>>(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! {
<Primitive
element=html::div
as_child=as_child
attrs=attrs
>
{children()}
</Primitive>
}
}

#[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<web_sys::HtmlElement>, options: Option<FocusOptions>) {
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<web_sys::HtmlElement>, Option<web_sys::HtmlElement>) {
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<web_sys::HtmlElement> {
let mut nodes: Vec<web_sys::HtmlElement> = vec![];

let accept_node_closure: Closure<dyn Fn(web_sys::Node) -> u32> =
Closure::new(move |node: web_sys::Node| -> u32 {
if let Some(html_element) = node.dyn_ref::<web_sys::HtmlElement>() {
if html_element.hidden() {
// NodeFilter.FILTER_SKIP
return 3;
}

if let Some(input_element) = node.dyn_ref::<web_sys::HtmlInputElement>() {
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::<web_sys::HtmlElement>() {
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<web_sys::HtmlElement>,
container: &web_sys::HtmlElement,
) -> Option<web_sys::HtmlElement> {
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<IsHiddenOptions>) -> 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<web_sys::Element> = 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<web_sys::HtmlElement>, options: Option<FocusOptions>) {
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::<web_sys::HtmlInputElement>()
.select();
}
}
}
9 changes: 9 additions & 0 deletions packages/primitives/leptos/focus-scope/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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::*;

0 comments on commit 81a7a69

Please sign in to comment.