app

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

commit 7a4c7c40d6409d0b7e3b69ecbb07b58699da7788
parent 2aa3ebb2b031c5c79b68e21d5f4bb925427e3717
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 20:23:08 +0000

ui: add modal aria hidden helper

- add modal guard that hides sibling nodes on open
- restore aria-hidden and inert attributes on close
- support layer stacking with per-element checks
- add unit test for modal layer tracking

Diffstat:
Mcrates/ui-primitives/Cargo.toml | 1+
Acrates/ui-primitives/src/aria_hidden.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ui-primitives/src/lib.rs | 9+++++++++
3 files changed, 223 insertions(+), 0 deletions(-)

diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml @@ -19,6 +19,7 @@ web-sys = { workspace = true, features = [ "Document", "Element", "EventTarget", + "HtmlCollection", "HtmlBodyElement", "FocusEvent", "HtmlElement", diff --git a/crates/ui-primitives/src/aria_hidden.rs b/crates/ui-primitives/src/aria_hidden.rs @@ -0,0 +1,213 @@ +use std::sync::{Mutex, OnceLock}; + +#[cfg(target_arch = "wasm32")] +use web_sys::{window, Element, HtmlCollection}; + +#[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; + } + } +} + +static MODAL_STATE: OnceLock<Mutex<ModalState>> = OnceLock::new(); + +fn modal_state() -> &'static Mutex<ModalState> { + MODAL_STATE.get_or_init(|| Mutex::new(ModalState::default())) +} + +pub fn radroots_app_ui_modal_hide_siblings( + root: &RadrootsAppUiModalTarget, +) -> RadrootsAppUiModalResult<RadrootsAppUiModalGuard> { + let mut state = modal_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + 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(RadrootsAppUiModalGuard { id, active: true }) +} + +pub fn radroots_app_ui_modal_restore(id: u64) -> RadrootsAppUiModalResult<()> { + let mut state = modal_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + 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 { + let state = modal_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + 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/lib.rs b/crates/ui-primitives/src/lib.rs @@ -6,6 +6,7 @@ mod dismissable; mod focus; mod scroll_lock; mod roving_focus; +mod aria_hidden; pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount}; pub use presence::{ @@ -37,3 +38,11 @@ pub use roving_focus::{ RadrootsAppUiRovingFocusAction, RadrootsAppUiRovingFocusOrientation, }; +pub use aria_hidden::{ + radroots_app_ui_modal_hide_siblings, + radroots_app_ui_modal_restore, + RadrootsAppUiModalError, + RadrootsAppUiModalGuard, + RadrootsAppUiModalResult, + RadrootsAppUiModalTarget, +};