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:
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,
+};