app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit d622a7926b0254aac40452eca76492c9c54edf80
parent f90f371ae12d10422f0764e6b7a8620112976684
Author: triesap <tyson@radroots.org>
Date:   Fri, 30 Jan 2026 14:52:06 +0000

ui: remove redundant primitives modules and deps

- delete superseded primitive modules in ui-primitives

- trim ui-primitives lib module declarations

- remove unused deps from ui-primitives cargo.toml

- update cargo.lock for dependency changes

Diffstat:
MCargo.lock | 5-----
Mcrates/ui-primitives/Cargo.toml | 21---------------------
Dcrates/ui-primitives/src/aria_hidden.rs | 229-------------------------------------------------------------------------------
Dcrates/ui-primitives/src/dismissable.rs | 165-------------------------------------------------------------------------------
Dcrates/ui-primitives/src/focus.rs | 213-------------------------------------------------------------------------------
Mcrates/ui-primitives/src/lib.rs | 8--------
Dcrates/ui-primitives/src/portal.rs | 37-------------------------------------
Dcrates/ui-primitives/src/presence.rs | 142-------------------------------------------------------------------------------
Dcrates/ui-primitives/src/roving_focus.rs | 132-------------------------------------------------------------------------------
Dcrates/ui-primitives/src/scroll_lock.rs | 186-------------------------------------------------------------------------------
10 files changed, 0 insertions(+), 1138 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1757,13 +1757,8 @@ dependencies = [ name = "radroots-app-ui-primitives" version = "0.1.0" dependencies = [ - "leptos", - "radroots-app-ui-core", - "send_wrapper", "ui-primitives-core", "ui-primitives-leptos", - "wasm-bindgen", - "web-sys", ] [[package]] diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml @@ -10,26 +10,5 @@ rust-version.workspace = true crate-type = ["rlib"] [dependencies] -radroots-app-ui-core = { path = "../ui-core" } -leptos = { workspace = true, features = ["csr"] } ui-primitives-core = { path = "../../refs/ui-primitives/crates/ui-primitives-core" } ui-primitives-leptos = { path = "../../refs/ui-primitives/crates/ui-primitives-leptos" } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = { workspace = true } -send_wrapper = { workspace = true } -web-sys = { workspace = true, features = [ - "CssStyleDeclaration", - "Document", - "Element", - "EventTarget", - "HtmlCollection", - "HtmlBodyElement", - "FocusEvent", - "HtmlElement", - "KeyboardEvent", - "Node", - "NodeList", - "PointerEvent", - "Window" -] } diff --git a/crates/ui-primitives/src/aria_hidden.rs b/crates/ui-primitives/src/aria_hidden.rs @@ -1,229 +0,0 @@ -#[cfg(target_arch = "wasm32")] -use std::cell::RefCell; - -#[cfg(not(target_arch = "wasm32"))] -use std::sync::{Mutex, OnceLock}; - -#[cfg(target_arch = "wasm32")] -use web_sys::{window, Element}; - -#[cfg(target_arch = "wasm32")] -#[derive(Debug, Clone)] -struct HiddenElement { - element: Element, - prev_aria_hidden: Option<String>, - prev_inert: bool, -} - -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, Clone)] -struct HiddenElement; - -#[derive(Debug, Clone)] -struct ModalLayer { - id: u64, - hidden: Vec<HiddenElement>, -} - -#[derive(Debug, Default)] -struct ModalState { - next_id: u64, - layers: Vec<ModalLayer>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsAppUiModalError { - WindowUnavailable, - DocumentUnavailable, - BodyUnavailable, - AttributeUnavailable, -} - -pub type RadrootsAppUiModalResult<T> = Result<T, RadrootsAppUiModalError>; - -#[cfg(target_arch = "wasm32")] -pub type RadrootsAppUiModalTarget = Element; - -#[cfg(not(target_arch = "wasm32"))] -pub type RadrootsAppUiModalTarget = (); - -#[derive(Debug)] -pub struct RadrootsAppUiModalGuard { - id: u64, - active: bool, -} - -impl Drop for RadrootsAppUiModalGuard { - fn drop(&mut self) { - if self.active { - let _ = radroots_app_ui_modal_restore(self.id); - self.active = false; - } - } -} - -#[cfg(target_arch = "wasm32")] -thread_local! { - static MODAL_STATE: RefCell<ModalState> = RefCell::new(ModalState::default()); -} - -#[cfg(not(target_arch = "wasm32"))] -static MODAL_STATE: OnceLock<Mutex<ModalState>> = OnceLock::new(); - -#[cfg(target_arch = "wasm32")] -fn modal_state_with<T>(f: impl FnOnce(&mut ModalState) -> T) -> T { - MODAL_STATE.with(|state| f(&mut state.borrow_mut())) -} - -#[cfg(not(target_arch = "wasm32"))] -fn modal_state_with<T>(f: impl FnOnce(&mut ModalState) -> T) -> T { - let mut state = MODAL_STATE - .get_or_init(|| Mutex::new(ModalState::default())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - f(&mut state) -} - -pub fn radroots_app_ui_modal_hide_siblings( - root: &RadrootsAppUiModalTarget, -) -> RadrootsAppUiModalResult<RadrootsAppUiModalGuard> { - let id = modal_state_with(|state| { - let id = state.next_id; - state.next_id = state.next_id.saturating_add(1); - let hidden = modal_collect_hidden(root)?; - state.layers.push(ModalLayer { id, hidden }); - Ok(id) - })?; - Ok(RadrootsAppUiModalGuard { id, active: true }) -} - -pub fn radroots_app_ui_modal_restore(id: u64) -> RadrootsAppUiModalResult<()> { - modal_state_with(|state| { - let index = state.layers.iter().position(|layer| layer.id == id); - let Some(index) = index else { - return Ok(()); - }; - let removed = state.layers.remove(index); - modal_restore_hidden(&state.layers, removed.hidden)?; - Ok(()) - }) -} - -#[cfg(target_arch = "wasm32")] -fn modal_collect_hidden(root: &Element) -> RadrootsAppUiModalResult<Vec<HiddenElement>> { - let window = window().ok_or(RadrootsAppUiModalError::WindowUnavailable)?; - let document = window - .document() - .ok_or(RadrootsAppUiModalError::DocumentUnavailable)?; - let body = document - .body() - .ok_or(RadrootsAppUiModalError::BodyUnavailable)?; - let children = body.children(); - let mut hidden = Vec::new(); - for index in 0..children.length() { - let Some(child) = children.item(index) else { - continue; - }; - if modal_is_root_or_ancestor(root, &child) { - continue; - } - let prev_aria_hidden = child.get_attribute("aria-hidden"); - let prev_inert = child.has_attribute("inert"); - child - .set_attribute("aria-hidden", "true") - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?; - child - .set_attribute("inert", "") - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?; - hidden.push(HiddenElement { - element: child, - prev_aria_hidden, - prev_inert, - }); - } - Ok(hidden) -} - -#[cfg(not(target_arch = "wasm32"))] -fn modal_collect_hidden(_root: &RadrootsAppUiModalTarget) -> RadrootsAppUiModalResult<Vec<HiddenElement>> { - Ok(Vec::new()) -} - -#[cfg(target_arch = "wasm32")] -fn modal_is_root_or_ancestor(root: &Element, candidate: &Element) -> bool { - candidate.is_same_node(Some(root)) || candidate.contains(Some(root)) -} - -#[cfg(target_arch = "wasm32")] -fn modal_restore_hidden( - layers: &[ModalLayer], - hidden: Vec<HiddenElement>, -) -> RadrootsAppUiModalResult<()> { - for item in hidden { - if modal_is_hidden_by_layers(layers, &item.element) { - continue; - } - match item.prev_aria_hidden { - Some(value) => item - .element - .set_attribute("aria-hidden", &value) - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?, - None => item - .element - .remove_attribute("aria-hidden") - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?, - } - if item.prev_inert { - item.element - .set_attribute("inert", "") - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?; - } else { - item.element - .remove_attribute("inert") - .map_err(|_| RadrootsAppUiModalError::AttributeUnavailable)?; - } - } - Ok(()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn modal_restore_hidden( - _layers: &[ModalLayer], - _hidden: Vec<HiddenElement>, -) -> RadrootsAppUiModalResult<()> { - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -fn modal_is_hidden_by_layers(layers: &[ModalLayer], element: &Element) -> bool { - layers.iter().any(|layer| { - layer.hidden.iter().any(|item| { - item.element.is_same_node(Some(element)) - }) - }) -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -#[cfg(not(target_arch = "wasm32"))] -fn modal_is_hidden_by_layers(_layers: &[ModalLayer], _element: &RadrootsAppUiModalTarget) -> bool { - false -} - -#[cfg(test)] -fn modal_layer_count_for_test() -> usize { - modal_state_with(|state| state.layers.len()) -} - -#[cfg(test)] -mod tests { - use super::{modal_layer_count_for_test, radroots_app_ui_modal_hide_siblings}; - - #[test] - fn modal_guard_tracks_layers() { - assert_eq!(modal_layer_count_for_test(), 0); - let guard = radroots_app_ui_modal_hide_siblings(&()).expect("guard"); - assert_eq!(modal_layer_count_for_test(), 1); - drop(guard); - assert_eq!(modal_layer_count_for_test(), 0); - } -} diff --git a/crates/ui-primitives/src/dismissable.rs b/crates/ui-primitives/src/dismissable.rs @@ -1,165 +0,0 @@ -use leptos::ev::{FocusEvent, KeyboardEvent, PointerEvent}; -use leptos::html; -use leptos::prelude::*; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsAppUiDismissableReason { - Escape, - PointerDownOutside, - FocusOutside, -} - -pub fn radroots_app_ui_dismissable_is_escape(key: &str) -> bool { - key == "Escape" -} - -pub fn radroots_app_ui_dismissable_is_outside(is_inside: bool) -> bool { - !is_inside -} - -#[component] -pub fn RadrootsAppUiDismissableLayer( - #[prop(optional)] on_dismiss: Option<Callback<RadrootsAppUiDismissableReason>>, - #[prop(optional)] on_escape_key_down: Option<Callback<KeyboardEvent>>, - #[prop(optional)] on_pointer_down_outside: Option<Callback<PointerEvent>>, - #[prop(optional)] on_focus_outside: Option<Callback<FocusEvent>>, - #[prop(optional)] disable_outside_pointer_events: bool, - children: ChildrenFn, -) -> impl IntoView { - let node_ref = NodeRef::<html::Div>::new(); - - let on_keydown = move |event: KeyboardEvent| { - if !radroots_app_ui_dismissable_is_escape(&event.key()) { - return; - } - if let Some(callback) = on_escape_key_down.as_ref() { - callback.run(event.clone()); - } - if let Some(callback) = on_dismiss.as_ref() { - callback.run(RadrootsAppUiDismissableReason::Escape); - } - }; - - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::closure::Closure; - use wasm_bindgen::JsCast; - use send_wrapper::SendWrapper; - - let on_dismiss = on_dismiss.clone(); - let on_pointer_down_outside = on_pointer_down_outside.clone(); - let on_focus_outside = on_focus_outside.clone(); - let node_ref = node_ref; - - node_ref.on_load(move |root| { - let document = match web_sys::window().and_then(|window| window.document()) { - Some(document) => document, - None => return, - }; - - if !disable_outside_pointer_events { - let root_pointer = root.clone(); - let on_dismiss = on_dismiss.clone(); - let on_pointer_down_outside = on_pointer_down_outside.clone(); - let handler = Closure::wrap(Box::new(move |event: web_sys::PointerEvent| { - let target = event - .target() - .and_then(|target| target.dyn_into::<web_sys::Node>().ok()); - let is_inside = target - .as_ref() - .map(|node| root_pointer.contains(Some(node))) - .unwrap_or(false); - if radroots_app_ui_dismissable_is_outside(is_inside) { - if let Some(callback) = on_pointer_down_outside.as_ref() { - callback.run(event.clone()); - } - if let Some(callback) = on_dismiss.as_ref() { - callback.run(RadrootsAppUiDismissableReason::PointerDownOutside); - } - } - }) as Box<dyn FnMut(_)>); - let _ = document.add_event_listener_with_callback( - "pointerdown", - handler.as_ref().unchecked_ref(), - ); - let cleanup_doc = SendWrapper::new(document.clone()); - let cleanup_handler = SendWrapper::new(handler); - on_cleanup(move || { - let document = cleanup_doc.take(); - let handler = cleanup_handler.take(); - let _ = document.remove_event_listener_with_callback( - "pointerdown", - handler.as_ref().unchecked_ref(), - ); - }); - } - - let root_focus = root.clone(); - let on_dismiss = on_dismiss.clone(); - let on_focus_outside = on_focus_outside.clone(); - let focus_handler = Closure::wrap(Box::new(move |event: web_sys::FocusEvent| { - let target = event - .target() - .and_then(|target| target.dyn_into::<web_sys::Node>().ok()); - let is_inside = target - .as_ref() - .map(|node| root_focus.contains(Some(node))) - .unwrap_or(false); - if radroots_app_ui_dismissable_is_outside(is_inside) { - if let Some(callback) = on_focus_outside.as_ref() { - callback.run(event.clone()); - } - if let Some(callback) = on_dismiss.as_ref() { - callback.run(RadrootsAppUiDismissableReason::FocusOutside); - } - } - }) as Box<dyn FnMut(_)>); - let _ = document.add_event_listener_with_callback( - "focusin", - focus_handler.as_ref().unchecked_ref(), - ); - let cleanup_doc = SendWrapper::new(document); - let cleanup_handler = SendWrapper::new(focus_handler); - on_cleanup(move || { - let document = cleanup_doc.take(); - let handler = cleanup_handler.take(); - let _ = document.remove_event_listener_with_callback( - "focusin", - handler.as_ref().unchecked_ref(), - ); - }); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = on_pointer_down_outside; - let _ = on_focus_outside; - let _ = disable_outside_pointer_events; - } - - view! { - <div node_ref=node_ref on:keydown=on_keydown> - {children()} - </div> - } -} - -#[cfg(test)] -mod tests { - use super::{ - radroots_app_ui_dismissable_is_escape, - radroots_app_ui_dismissable_is_outside, - }; - - #[test] - fn dismissable_escape_match() { - assert!(radroots_app_ui_dismissable_is_escape("Escape")); - assert!(!radroots_app_ui_dismissable_is_escape("Enter")); - } - - #[test] - fn dismissable_outside_check() { - assert!(radroots_app_ui_dismissable_is_outside(false)); - assert!(!radroots_app_ui_dismissable_is_outside(true)); - } -} diff --git a/crates/ui-primitives/src/focus.rs b/crates/ui-primitives/src/focus.rs @@ -1,213 +0,0 @@ -use leptos::ev::KeyboardEvent; -use leptos::html; -use leptos::prelude::*; - -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::JsCast; - -const RADROOTS_APP_UI_FOCUSABLE_SELECTOR: &str = - "a[href],button,textarea,input,select,[tabindex]:not([tabindex='-1'])"; - -pub fn radroots_app_ui_focus_scope_selector() -> &'static str { - RADROOTS_APP_UI_FOCUSABLE_SELECTOR -} - -pub fn radroots_app_ui_focus_scope_next_index( - current: usize, - count: usize, - shift: bool, -) -> usize { - if count == 0 { - return 0; - } - if shift { - if current == 0 { - count - 1 - } else { - current - 1 - } - } else if current + 1 >= count { - 0 - } else { - current + 1 - } -} - -#[component] -pub fn RadrootsAppUiFocusScope( - #[prop(optional)] trapped: bool, - #[prop(optional)] auto_focus: bool, - #[prop(optional)] return_focus: bool, - #[prop(optional)] on_mount_auto_focus: Option<Callback<()>>, - #[prop(optional)] on_unmount_auto_focus: Option<Callback<()>>, - children: ChildrenFn, -) -> impl IntoView { - let node_ref = NodeRef::<html::Div>::new(); - - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::closure::Closure; - use send_wrapper::SendWrapper; - - let on_mount_auto_focus = on_mount_auto_focus.clone(); - let on_unmount_auto_focus = on_unmount_auto_focus.clone(); - let node_ref = node_ref; - - node_ref.on_load(move |root| { - let document = match web_sys::window().and_then(|window| window.document()) { - Some(document) => document, - None => return, - }; - let previous_focus = document.active_element().map(SendWrapper::new); - - if auto_focus { - let _ = radroots_app_ui_focus_scope_focus_first(&root, &document); - if let Some(callback) = on_mount_auto_focus.as_ref() { - callback.run(()); - } - } - - if trapped { - let root_focus = root.clone(); - let document_focus = document.clone(); - let handler = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { - if event.key() != "Tab" { - return; - } - let _ = radroots_app_ui_focus_scope_cycle( - &root_focus, - &document_focus, - event.shift_key(), - ); - }) as Box<dyn FnMut(_)>); - let _ = root.add_event_listener_with_callback( - "keydown", - handler.as_ref().unchecked_ref(), - ); - handler.forget(); - } - - if return_focus { - let on_unmount_auto_focus = on_unmount_auto_focus.clone(); - let previous_focus = previous_focus; - on_cleanup(move || { - if let Some(element) = previous_focus { - let element = element.take(); - let _ = element - .dyn_ref::<web_sys::HtmlElement>() - .map(|el| el.focus()); - } - if let Some(callback) = on_unmount_auto_focus.as_ref() { - callback.run(()); - } - }); - } - }); - } - - #[cfg(not(target_arch = "wasm32"))] - { - let _ = trapped; - let _ = auto_focus; - let _ = return_focus; - let _ = on_mount_auto_focus; - let _ = on_unmount_auto_focus; - } - - let on_keydown = move |event: KeyboardEvent| { - if trapped && event.key() == "Escape" { - event.prevent_default(); - } - }; - - view! { - <div node_ref=node_ref tabindex="-1" on:keydown=on_keydown> - {children()} - </div> - } -} - -#[cfg(target_arch = "wasm32")] -fn radroots_app_ui_focus_scope_focus_first( - root: &web_sys::Element, - document: &web_sys::Document, -) -> Result<(), ()> { - let list = root - .query_selector_all(RADROOTS_APP_UI_FOCUSABLE_SELECTOR) - .map_err(|_| ())?; - if list.length() == 0 { - if let Some(element) = root.dyn_ref::<web_sys::HtmlElement>() { - let _ = element.focus(); - } - return Ok(()); - } - let first = list - .item(0) - .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok()) - .or_else(|| { - document - .active_element() - .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) - }); - if let Some(element) = first { - let _ = element.focus(); - } - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -fn radroots_app_ui_focus_scope_cycle( - root: &web_sys::Element, - document: &web_sys::Document, - shift: bool, -) -> Result<(), ()> { - let list = root - .query_selector_all(RADROOTS_APP_UI_FOCUSABLE_SELECTOR) - .map_err(|_| ())?; - let count = list.length() as usize; - if count == 0 { - return Ok(()); - } - let active = document.active_element(); - let mut current_index = 0usize; - if let Some(active) = active { - for index in 0..count { - let candidate = list - .item(index as u32) - .and_then(|node| node.dyn_into::<web_sys::Element>().ok()); - if let Some(candidate) = candidate { - if active.is_same_node(Some(&candidate)) { - current_index = index; - break; - } - } - } - } - let next_index = radroots_app_ui_focus_scope_next_index(current_index, count, shift); - if let Some(next) = list - .item(next_index as u32) - .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok()) - { - let _ = next.focus(); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - radroots_app_ui_focus_scope_next_index, - radroots_app_ui_focus_scope_selector, - }; - - #[test] - fn focus_scope_selector_is_non_empty() { - assert!(!radroots_app_ui_focus_scope_selector().is_empty()); - } - - #[test] - fn focus_scope_next_index_wraps() { - assert_eq!(radroots_app_ui_focus_scope_next_index(0, 3, true), 2); - assert_eq!(radroots_app_ui_focus_scope_next_index(2, 3, false), 0); - } -} diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs @@ -1,13 +1,5 @@ #![forbid(unsafe_code)] -mod portal; -mod presence; -mod dismissable; -mod focus; -mod scroll_lock; -mod roving_focus; -mod aria_hidden; - pub use ui_primitives_core::dialog::DialogModel; pub use ui_primitives_core::roving_focus::{ roving_focus_action_from_key as radroots_app_ui_roving_focus_action_from_key, diff --git a/crates/ui-primitives/src/portal.rs b/crates/ui-primitives/src/portal.rs @@ -1,37 +0,0 @@ -use leptos::prelude::*; - -#[cfg(target_arch = "wasm32")] -use leptos::portal::Portal; - -#[cfg(target_arch = "wasm32")] -pub type RadrootsAppUiPortalMount = web_sys::Element; - -#[cfg(not(target_arch = "wasm32"))] -pub type RadrootsAppUiPortalMount = (); - -#[component] -pub fn RadrootsAppUiPortal( - #[prop(optional)] mount: Option<RadrootsAppUiPortalMount>, - children: ChildrenFn, -) -> impl IntoView { - #[cfg(target_arch = "wasm32")] - { - match mount { - Some(mount) => view! { <Portal mount=mount>{children()}</Portal> }, - None => view! { <Portal>{children()}</Portal> }, - } - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = mount; - children() - } -} - -#[cfg(test)] -mod tests { - #[test] - fn portal_availability_matches_target() { - assert!(!cfg!(target_arch = "wasm32")); - } -} diff --git a/crates/ui-primitives/src/presence.rs b/crates/ui-primitives/src/presence.rs @@ -1,142 +0,0 @@ -use leptos::ev::{AnimationEvent, TransitionEvent}; -use leptos::prelude::*; -use std::sync::Arc; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsAppUiPresenceState { - Mounted, - Exiting, - Unmounted, -} - -pub fn radroots_app_ui_presence_state_next( - current: RadrootsAppUiPresenceState, - present: bool, - exit_complete: bool, -) -> RadrootsAppUiPresenceState { - match (current, present, exit_complete) { - (_, true, _) => RadrootsAppUiPresenceState::Mounted, - (RadrootsAppUiPresenceState::Mounted, false, true) => { - RadrootsAppUiPresenceState::Unmounted - } - (RadrootsAppUiPresenceState::Mounted, false, false) => { - RadrootsAppUiPresenceState::Exiting - } - (RadrootsAppUiPresenceState::Exiting, false, true) => { - RadrootsAppUiPresenceState::Unmounted - } - (RadrootsAppUiPresenceState::Exiting, false, false) => { - RadrootsAppUiPresenceState::Exiting - } - (RadrootsAppUiPresenceState::Unmounted, false, _) => { - RadrootsAppUiPresenceState::Unmounted - } - } -} - -#[component] -pub fn RadrootsAppUiPresence( - #[prop(into)] present: Signal<bool>, - #[prop(optional)] on_exit_complete: Option<Callback<()>>, - children: ChildrenFn, -) -> impl IntoView { - let state = RwSignal::new(if present.get_untracked() { - RadrootsAppUiPresenceState::Mounted - } else { - RadrootsAppUiPresenceState::Unmounted - }); - - Effect::new(move || { - let next = radroots_app_ui_presence_state_next(state.get(), present.get(), false); - if next != state.get() { - state.set(next); - } - }); - - let on_exit_complete = on_exit_complete.clone(); - let end_handler = Arc::new(move || { - let current_state = state.get_untracked(); - let next = radroots_app_ui_presence_state_next( - current_state, - present.get_untracked(), - true, - ); - if next != current_state { - state.set(next); - if next == RadrootsAppUiPresenceState::Unmounted { - if let Some(callback) = on_exit_complete.as_ref() { - callback.run(()); - } - } - } - }); - - let render = move || -> AnyView { - if state.get() == RadrootsAppUiPresenceState::Unmounted { - ().into_any() - } else { - let transition_end = { - let end_handler = Arc::clone(&end_handler); - move |_event: TransitionEvent| { - end_handler(); - } - }; - let animation_end = { - let end_handler = Arc::clone(&end_handler); - move |_event: AnimationEvent| { - end_handler(); - } - }; - view! { - <div - data-state=move || if present.get() { "open" } else { "closed" } - on:transitionend=transition_end - on:animationend=animation_end - > - {children()} - </div> - } - .into_any() - } - }; - - view! { {render} } -} - -#[cfg(test)] -mod tests { - use super::{ - radroots_app_ui_presence_state_next, - RadrootsAppUiPresenceState, - }; - - #[test] - fn presence_state_moves_to_exiting_on_close() { - let next = radroots_app_ui_presence_state_next( - RadrootsAppUiPresenceState::Mounted, - false, - false, - ); - assert_eq!(next, RadrootsAppUiPresenceState::Exiting); - } - - #[test] - fn presence_state_unmounts_after_exit() { - let next = radroots_app_ui_presence_state_next( - RadrootsAppUiPresenceState::Exiting, - false, - true, - ); - assert_eq!(next, RadrootsAppUiPresenceState::Unmounted); - } - - #[test] - fn presence_state_mounts_when_present() { - let next = radroots_app_ui_presence_state_next( - RadrootsAppUiPresenceState::Unmounted, - true, - false, - ); - assert_eq!(next, RadrootsAppUiPresenceState::Mounted); - } -} diff --git a/crates/ui-primitives/src/roving_focus.rs b/crates/ui-primitives/src/roving_focus.rs @@ -1,132 +0,0 @@ -use ui_primitives_core::roving_focus::RovingFocus; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsAppUiRovingFocusOrientation { - Horizontal, - Vertical, - Both, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsAppUiRovingFocusAction { - Next, - Prev, - First, - Last, -} - -pub fn radroots_app_ui_roving_focus_action_from_key( - key: &str, - orientation: RadrootsAppUiRovingFocusOrientation, -) -> Option<RadrootsAppUiRovingFocusAction> { - match key { - "Home" => Some(RadrootsAppUiRovingFocusAction::First), - "End" => Some(RadrootsAppUiRovingFocusAction::Last), - "ArrowLeft" => matches!( - orientation, - RadrootsAppUiRovingFocusOrientation::Horizontal | RadrootsAppUiRovingFocusOrientation::Both - ) - .then_some(RadrootsAppUiRovingFocusAction::Prev), - "ArrowRight" => matches!( - orientation, - RadrootsAppUiRovingFocusOrientation::Horizontal | RadrootsAppUiRovingFocusOrientation::Both - ) - .then_some(RadrootsAppUiRovingFocusAction::Next), - "ArrowUp" => matches!( - orientation, - RadrootsAppUiRovingFocusOrientation::Vertical | RadrootsAppUiRovingFocusOrientation::Both - ) - .then_some(RadrootsAppUiRovingFocusAction::Prev), - "ArrowDown" => matches!( - orientation, - RadrootsAppUiRovingFocusOrientation::Vertical | RadrootsAppUiRovingFocusOrientation::Both - ) - .then_some(RadrootsAppUiRovingFocusAction::Next), - _ => None, - } -} - -pub fn radroots_app_ui_roving_focus_next_index( - current: usize, - count: usize, - action: RadrootsAppUiRovingFocusAction, - looped: bool, -) -> usize { - if count == 0 { - return 0; - } - - let mut focus = RovingFocus::with_active(count, Some(current), looped); - match action { - RadrootsAppUiRovingFocusAction::First => focus.move_first().unwrap_or(0), - RadrootsAppUiRovingFocusAction::Last => focus.move_last().unwrap_or(0), - RadrootsAppUiRovingFocusAction::Next => focus.move_next().unwrap_or(current), - RadrootsAppUiRovingFocusAction::Prev => focus.move_prev().unwrap_or(current), - } -} - -#[cfg(test)] -mod tests { - use super::{ - radroots_app_ui_roving_focus_action_from_key, - radroots_app_ui_roving_focus_next_index, - RadrootsAppUiRovingFocusAction, - RadrootsAppUiRovingFocusOrientation, - }; - - #[test] - fn roving_focus_action_maps_arrows() { - assert_eq!( - radroots_app_ui_roving_focus_action_from_key( - "ArrowLeft", - RadrootsAppUiRovingFocusOrientation::Horizontal - ), - Some(RadrootsAppUiRovingFocusAction::Prev) - ); - assert_eq!( - radroots_app_ui_roving_focus_action_from_key( - "ArrowUp", - RadrootsAppUiRovingFocusOrientation::Horizontal - ), - None - ); - assert_eq!( - radroots_app_ui_roving_focus_action_from_key( - "ArrowDown", - RadrootsAppUiRovingFocusOrientation::Both - ), - Some(RadrootsAppUiRovingFocusAction::Next) - ); - } - - #[test] - fn roving_focus_next_index_respects_loop() { - assert_eq!( - radroots_app_ui_roving_focus_next_index( - 0, - 3, - RadrootsAppUiRovingFocusAction::Prev, - false - ), - 0 - ); - assert_eq!( - radroots_app_ui_roving_focus_next_index( - 0, - 3, - RadrootsAppUiRovingFocusAction::Prev, - true - ), - 2 - ); - assert_eq!( - radroots_app_ui_roving_focus_next_index( - 2, - 3, - RadrootsAppUiRovingFocusAction::Next, - true - ), - 0 - ); - } -} diff --git a/crates/ui-primitives/src/scroll_lock.rs b/crates/ui-primitives/src/scroll_lock.rs @@ -1,186 +0,0 @@ -use std::sync::{Mutex, OnceLock}; - -#[cfg(target_arch = "wasm32")] -use web_sys::{window, CssStyleDeclaration}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsAppUiScrollLockError { - WindowUnavailable, - DocumentUnavailable, - BodyUnavailable, - StyleUnavailable, -} - -pub type RadrootsAppUiScrollLockResult<T> = Result<T, RadrootsAppUiScrollLockError>; - -#[derive(Debug)] -pub struct RadrootsAppUiScrollLockGuard { - active: bool, -} - -impl Drop for RadrootsAppUiScrollLockGuard { - fn drop(&mut self) { - if self.active { - let _ = radroots_app_ui_scroll_lock_release(); - self.active = false; - } - } -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -#[derive(Debug, Default, Clone)] -struct ScrollLockSnapshot { - scroll_y: f64, - overflow: String, - position: String, - top: String, - width: String, -} - -#[derive(Debug, Default)] -struct ScrollLockState { - count: usize, - snapshot: Option<ScrollLockSnapshot>, -} - -static SCROLL_LOCK_STATE: OnceLock<Mutex<ScrollLockState>> = OnceLock::new(); - -fn scroll_lock_state() -> &'static Mutex<ScrollLockState> { - SCROLL_LOCK_STATE.get_or_init(|| Mutex::new(ScrollLockState::default())) -} - -pub fn radroots_app_ui_scroll_lock_acquire() -> RadrootsAppUiScrollLockResult<RadrootsAppUiScrollLockGuard> { - let mut state = scroll_lock_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if state.count == 0 { - scroll_lock_apply(&mut state)?; - } - state.count += 1; - Ok(RadrootsAppUiScrollLockGuard { active: true }) -} - -pub fn radroots_app_ui_scroll_lock_release() -> RadrootsAppUiScrollLockResult<()> { - let mut state = scroll_lock_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if state.count == 0 { - return Ok(()); - } - state.count = state.count.saturating_sub(1); - if state.count == 0 { - scroll_lock_restore(&mut state)?; - } - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -fn scroll_lock_apply(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { - let window = window().ok_or(RadrootsAppUiScrollLockError::WindowUnavailable)?; - let document = window - .document() - .ok_or(RadrootsAppUiScrollLockError::DocumentUnavailable)?; - let body = document - .body() - .ok_or(RadrootsAppUiScrollLockError::BodyUnavailable)?; - let style = body.style(); - let scroll_y = window - .scroll_y() - .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable)?; - let snapshot = ScrollLockSnapshot { - scroll_y, - overflow: style_value(&style, "overflow")?, - position: style_value(&style, "position")?, - top: style_value(&style, "top")?, - width: style_value(&style, "width")?, - }; - style_set(&style, "overflow", "hidden")?; - style_set(&style, "position", "fixed")?; - style_set(&style, "width", "100%")?; - style_set(&style, "top", &format!("-{}px", snapshot.scroll_y))?; - state.snapshot = Some(snapshot); - Ok(()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn scroll_lock_apply(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { - state.snapshot = None; - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -fn scroll_lock_restore(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { - let Some(snapshot) = state.snapshot.take() else { - return Ok(()); - }; - let window = window().ok_or(RadrootsAppUiScrollLockError::WindowUnavailable)?; - let document = window - .document() - .ok_or(RadrootsAppUiScrollLockError::DocumentUnavailable)?; - let body = document - .body() - .ok_or(RadrootsAppUiScrollLockError::BodyUnavailable)?; - let style = body.style(); - style_set(&style, "overflow", &snapshot.overflow)?; - style_set(&style, "position", &snapshot.position)?; - style_set(&style, "top", &snapshot.top)?; - style_set(&style, "width", &snapshot.width)?; - window.scroll_to_with_x_and_y(0.0, snapshot.scroll_y); - Ok(()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn scroll_lock_restore(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { - state.snapshot = None; - Ok(()) -} - -#[cfg(target_arch = "wasm32")] -fn style_set( - style: &CssStyleDeclaration, - name: &str, - value: &str, -) -> RadrootsAppUiScrollLockResult<()> { - style - .set_property(name, value) - .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable) -} - -#[cfg(target_arch = "wasm32")] -fn style_value(style: &CssStyleDeclaration, name: &str) -> RadrootsAppUiScrollLockResult<String> { - style - .get_property_value(name) - .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable) -} - -#[cfg(test)] -fn scroll_lock_count_for_test() -> usize { - let state = scroll_lock_state() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.count -} - -#[cfg(test)] -mod tests { - use super::{ - radroots_app_ui_scroll_lock_acquire, - radroots_app_ui_scroll_lock_release, - scroll_lock_count_for_test, - }; - - #[test] - fn scroll_lock_guard_updates_count() { - assert_eq!(scroll_lock_count_for_test(), 0); - let guard = radroots_app_ui_scroll_lock_acquire().expect("lock"); - assert_eq!(scroll_lock_count_for_test(), 1); - drop(guard); - assert_eq!(scroll_lock_count_for_test(), 0); - } - - #[test] - fn scroll_lock_release_is_idempotent() { - let _ = radroots_app_ui_scroll_lock_release().expect("release"); - assert_eq!(scroll_lock_count_for_test(), 0); - } -}