use crate::accessibility::{AXNode, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{Severity, Violation};
use super::{JourneyCandidate, JourneyKind, PatternAnalysis, PatternConfidence, PatternKind};
const NATIVE_DISCLOSURE_ROLES: &[&str] = &["DisclosureTriangle", "DisclosureTriangleGrouped"];
fn triggers(tree: &AXTree) -> (Vec<&AXNode>, Vec<&AXNode>) {
let aria = tree
.nodes_with_role("button")
.into_iter()
.filter(|n| n.get_property_bool("expanded").is_some())
.collect();
let native = NATIVE_DISCLOSURE_ROLES
.iter()
.flat_map(|role| tree.nodes_with_role(role))
.filter(|n| n.get_property_bool("expanded").is_some())
.collect();
(aria, native)
}
fn plural(n: usize) -> &'static str {
if n == 1 {
"trigger"
} else {
"triggers"
}
}
pub fn detect(tree: &AXTree, out: &mut PatternAnalysis) {
let (aria, native) = triggers(tree);
if !aria.is_empty() || !native.is_empty() {
recognize(&aria, &native, out);
emit_candidates(&aria, &native, out);
}
if !aria.is_empty() {
flag_undeclared_menu_triggers(tree, out);
}
}
fn recognize(aria: &[&AXNode], native: &[&AXNode], out: &mut PatternAnalysis) {
let disclosure_count = aria.len();
let with_controls = aria.iter().filter(|n| n.has_property("controls")).count();
if disclosure_count == 0 {
out.add_recognized(
"DisclosureMenu",
format!(
"{} native disclosure {} (<details>/<summary>) — semantics come from the element, no ARIA needed.",
native.len(),
plural(native.len())
),
PatternConfidence::Strong,
);
return;
}
let confidence = if with_controls == disclosure_count {
PatternConfidence::Strong
} else {
PatternConfidence::Partial
};
let mut detail = if with_controls == disclosure_count {
format!(
"{} disclosure {} with aria-expanded and aria-controls — well-formed pattern.",
disclosure_count,
plural(disclosure_count)
)
} else {
format!(
"{} disclosure {} with aria-expanded ({} with aria-controls). Controls relationship strengthens screen-reader announcements.",
disclosure_count,
plural(disclosure_count),
with_controls
)
};
if !native.is_empty() {
detail.push_str(&format!(
" Plus {} native <details>/<summary> {}.",
native.len(),
plural(native.len())
));
}
out.add_recognized("DisclosureMenu", detail, confidence);
}
fn emit_candidates(aria: &[&AXNode], native: &[&AXNode], out: &mut PatternAnalysis) {
for btn in aria {
let haspopup = btn.haspopup();
let is_menu = matches!(haspopup, Some("menu") | Some("true"));
let has_controls = btn.has_property("controls");
if let Some(bid) = btn.backend_dom_node_id {
out.journey_candidates.push(JourneyCandidate {
pattern_kind: if is_menu {
PatternKind::Menu
} else {
PatternKind::Disclosure
},
trigger_backend_id: Some(bid),
controlled_backend_id: None,
confidence: if has_controls { 0.8 } else { 0.7 },
required_journey: if is_menu {
JourneyKind::MenuOpen
} else {
JourneyKind::DisclosureToggle
},
});
}
}
for summary in native {
if let Some(bid) = summary.backend_dom_node_id {
out.journey_candidates.push(JourneyCandidate {
pattern_kind: PatternKind::Disclosure,
trigger_backend_id: Some(bid),
controlled_backend_id: None,
confidence: 0.9,
required_journey: JourneyKind::DisclosureToggle,
});
}
}
}
fn flag_undeclared_menu_triggers(tree: &AXTree, out: &mut PatternAnalysis) {
for node in tree.iter() {
let role = node.role.as_deref().unwrap_or("");
if role != "generic" && role != "link" {
continue;
}
let name = node.name.as_deref().unwrap_or("").to_lowercase();
let looks_like_menu = name.contains("menu") || name.contains("menü");
if !looks_like_menu {
continue;
}
if node.get_property_bool("expanded").is_some() {
continue;
}
let has_focusable_descendant = node
.child_ids
.iter()
.filter_map(|id| tree.get_node(id))
.any(|c| c.get_property_bool("focusable").unwrap_or(false));
if !has_focusable_descendant {
continue;
}
out.violations.push(
Violation::new(
"4.1.2",
"Name, Role, Value",
WcagLevel::A,
Severity::Medium,
format!(
"Likely disclosure menu trigger (\"{}\") lacks aria-expanded — screen readers cannot announce open/closed state.",
node.name.as_deref().unwrap_or("(menu)")
),
&node.node_id,
)
.with_fix(
"Use a native <button> with aria-expanded=\"true|false\" toggled by the click handler.",
)
.with_rule_id("aria-expanded-required")
.with_help_url("https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/"),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn node_with_prop(id: &str, role: &str, prop: AXProperty) -> AXNode {
let mut n = AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: None,
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
};
n.properties.push(prop);
n
}
#[test]
fn test_button_with_expanded_recognized() {
let tree = AXTree::from_nodes(vec![node_with_prop(
"1",
"button",
AXProperty {
name: "expanded".into(),
value: AXValue::Bool(false),
},
)]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.recognized.len(), 1);
assert_eq!(a.recognized[0].pattern, "DisclosureMenu");
}
#[test]
fn test_button_with_expanded_and_controls_strong() {
let mut n = node_with_prop(
"1",
"button",
AXProperty {
name: "expanded".into(),
value: AXValue::Bool(false),
},
);
n.properties.push(AXProperty {
name: "controls".into(),
value: AXValue::String("menu-1".into()),
});
let tree = AXTree::from_nodes(vec![n]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.recognized[0].confidence, PatternConfidence::Strong);
}
#[test]
fn test_nothing_without_buttons() {
let tree = AXTree::from_nodes(vec![]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert!(a.recognized.is_empty());
assert!(a.journey_candidates.is_empty());
}
fn expanded(id: &str, role: &str, backend: i64) -> AXNode {
let mut n = node_with_prop(
id,
role,
AXProperty {
name: "expanded".into(),
value: AXValue::Bool(false),
},
);
n.backend_dom_node_id = Some(backend);
n
}
#[test]
fn natives_summary_wird_zum_journey_kandidaten() {
let tree = AXTree::from_nodes(vec![expanded("1", "DisclosureTriangle", 11)]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.recognized.len(), 1);
assert_eq!(a.recognized[0].confidence, PatternConfidence::Strong);
assert_eq!(a.journey_candidates.len(), 1);
let c = &a.journey_candidates[0];
assert_eq!(c.trigger_backend_id, Some(11));
assert_eq!(c.required_journey, JourneyKind::DisclosureToggle);
assert_eq!(c.pattern_kind, PatternKind::Disclosure);
}
#[test]
fn gruppiertes_details_zaehlt_ebenso() {
let tree = AXTree::from_nodes(vec![expanded("1", "DisclosureTriangleGrouped", 12)]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.journey_candidates.len(), 1);
}
#[test]
fn summary_ohne_zustand_ist_kein_kandidat() {
let mut n = node_with_prop(
"1",
"DisclosureTriangle",
AXProperty {
name: "focusable".into(),
value: AXValue::Bool(true),
},
);
n.backend_dom_node_id = Some(13);
let tree = AXTree::from_nodes(vec![n]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert!(a.recognized.is_empty());
assert!(a.journey_candidates.is_empty());
}
#[test]
fn beide_herkuenfte_nebeneinander() {
let tree = AXTree::from_nodes(vec![
expanded("1", "button", 21),
expanded("2", "DisclosureTriangle", 22),
]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.journey_candidates.len(), 2);
assert!(a.recognized[0]
.message
.contains("native <details>/<summary>"));
}
}