use crate::accessibility::{AXNode, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub const INSTRUCTIONS_RULE: RuleMetadata = RuleMetadata {
id: "3.3.2",
name: "Labels or Instructions",
level: WcagLevel::A,
severity: Severity::High,
description: "Labels or instructions are provided when content requires user input",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html",
axe_id: "label",
tags: &["wcag2a", "wcag332", "cat.forms"],
};
pub fn check_instructions(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
for node in tree.iter() {
if node.ignored {
continue;
}
results.nodes_checked += 1;
let role_lower = node.role.as_deref().unwrap_or("").to_lowercase();
if is_form_input(&role_lower) {
let has_label = has_accessible_label(node);
let has_placeholder_text = has_placeholder(node);
let has_instructions = has_instructions_or_hint(node);
if !has_label {
let violation = Violation::new(
INSTRUCTIONS_RULE.id,
INSTRUCTIONS_RULE.name,
INSTRUCTIONS_RULE.level,
Severity::Critical,
format!("Form control '{}' has no accessible label", role_lower),
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix("Add a <label> element, aria-label, or aria-labelledby attribute")
.with_help_url(INSTRUCTIONS_RULE.help_url);
results.add_violation(violation);
continue;
}
if !has_label && has_placeholder_text && !has_instructions {
let violation = Violation::new(
INSTRUCTIONS_RULE.id,
INSTRUCTIONS_RULE.name,
INSTRUCTIONS_RULE.level,
Severity::Medium,
"Placeholder used as only label",
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix("Add a visible <label> element. Placeholder should supplement, not replace, labels")
.with_help_url(INSTRUCTIONS_RULE.help_url);
results.add_violation(violation);
}
if is_required(node) && !indicates_required(node) {
let violation = Violation::new(
INSTRUCTIONS_RULE.id,
INSTRUCTIONS_RULE.name,
INSTRUCTIONS_RULE.level,
Severity::Medium,
"Required field not clearly indicated",
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix("Add visual indicator (e.g., asterisk *) and screen reader text for required fields")
.with_help_url(INSTRUCTIONS_RULE.help_url);
results.add_violation(violation);
}
if needs_format_instructions(&role_lower, node) && !has_format_hint(node) {
let violation = Violation::new(
INSTRUCTIONS_RULE.id,
INSTRUCTIONS_RULE.name,
INSTRUCTIONS_RULE.level,
Severity::Low,
format!("Input '{}' may require format instructions", role_lower),
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix("Consider adding format instructions (e.g., 'DD/MM/YYYY' for dates)")
.with_help_url(INSTRUCTIONS_RULE.help_url);
results.add_violation(violation);
}
if has_label {
results.passes += 1;
}
}
if role_lower == "radiogroup" || (role_lower == "group" && is_form_group(node, tree)) {
if !has_group_label(node) {
let violation = Violation::new(
INSTRUCTIONS_RULE.id,
INSTRUCTIONS_RULE.name,
INSTRUCTIONS_RULE.level,
Severity::Medium,
"Form group has no legend or label",
&node.node_id,
)
.with_role(node.role.clone())
.with_fix("Use <fieldset> with <legend>, or add aria-labelledby to the group")
.with_help_url(INSTRUCTIONS_RULE.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
}
results
}
fn is_form_input(role: &str) -> bool {
matches!(
role,
"textbox"
| "searchbox"
| "combobox"
| "listbox"
| "spinbutton"
| "slider"
| "checkbox"
| "radio"
| "switch"
| "textarea"
)
}
fn has_accessible_label(node: &AXNode) -> bool {
if let Some(name) = &node.name {
if !name.trim().is_empty() {
return true;
}
}
false
}
fn has_placeholder(node: &AXNode) -> bool {
node.get_property_str("placeholder")
.map(|s| !s.is_empty())
.unwrap_or(false)
}
fn has_instructions_or_hint(node: &AXNode) -> bool {
if let Some(desc) = &node.description {
if !desc.trim().is_empty() {
return true;
}
}
false
}
fn is_required(node: &AXNode) -> bool {
node.get_property_bool("required").unwrap_or(false)
}
fn indicates_required(node: &AXNode) -> bool {
if let Some(name) = &node.name {
let name_lower = name.to_lowercase();
let required_terms = [
"required",
"pflichtfeld",
"pflicht",
"erforderlich",
"obligatoire",
"requis",
"champ obligatoire",
"obligatorio",
"requerido",
"campo requerido",
"obbligatorio",
"richiesto",
"obrigatório",
"campo obrigatório",
"verplicht",
"vereist",
"obligatoriskt",
"krävs",
"wymagane",
"zorunlu",
];
if required_terms
.iter()
.any(|t| name_lower.contains(t) || name_lower.contains('*'))
{
return true;
}
}
if let Some(desc) = &node.description {
let desc_lower = desc.to_lowercase();
if desc_lower.contains("required")
|| desc_lower.contains("pflichtfeld")
|| desc_lower.contains("obligatoire")
|| desc_lower.contains("obligatorio")
|| desc_lower.contains("obbligatorio")
|| desc_lower.contains("obrigatório")
|| desc_lower.contains("verplicht")
{
return true;
}
}
false
}
fn needs_format_instructions(role: &str, node: &AXNode) -> bool {
let name = node.name.as_deref().unwrap_or("").to_lowercase();
let format_sensitive = [
"date",
"phone",
"tel",
"zip",
"postal",
"credit card",
"ssn",
"social security",
"passport",
"account",
"routing",
"datum",
"telefon",
"postleitzahl",
"plz",
"kreditkarte",
"reisepass",
"kontonummer",
"téléphone",
"code postal",
"carte de crédit",
"passeport",
"numéro de compte",
"teléfono",
"código postal",
"tarjeta de crédito",
"pasaporte",
"número de cuenta",
"telefono",
"codice postale",
"carta di credito",
"passaporto",
"numero di conto",
"telefone",
"código postal",
"cartão de crédito",
"passaporte",
"telefoon",
"postcode",
"creditcard",
"paspoort",
"rekeningnummer",
"telefon",
"postnummer",
"kreditkort",
"pass",
"telefon",
"kod pocztowy",
"karta kredytowa",
"paszport",
"telefon",
"posta kodu",
"kredi kartı",
"pasaport",
];
format_sensitive.iter().any(|&term| name.contains(term)) || role == "spinbutton"
}
fn has_format_hint(node: &AXNode) -> bool {
let format_patterns = [
"format:",
"example:",
"e.g.",
"(",
"mm/dd",
"yyyy",
"z.b.",
"bsp.",
"beispiel:",
"ex.",
"exemple:",
"par ex.",
"ej.",
"ejemplo:",
"p.ej.",
"es.",
"esempio:",
"ex.:",
"exemplo:",
"bijv.",
"voorbeeld:",
"t.ex.",
"np.",
"przykład:",
];
if let Some(name) = &node.name {
let name_lower = name.to_lowercase();
if format_patterns.iter().any(|p| name_lower.contains(p)) {
return true;
}
}
if let Some(desc) = &node.description {
let desc_lower = desc.to_lowercase();
if format_patterns.iter().any(|p| desc_lower.contains(p)) {
return true;
}
}
has_placeholder(node)
}
fn is_form_group(node: &AXNode, tree: &AXTree) -> bool {
if let Some(tag) = node.get_property_str("htmlTag") {
let tag_up = tag.to_uppercase();
if tag_up == "FIELDSET" {
return true;
}
if matches!(
tag_up.as_str(),
"DETAILS" | "ADDRESS" | "HGROUP" | "FIGURE" | "ARTICLE" | "SECTION" | "ASIDE"
) {
return false;
}
}
has_form_control_descendant(node, tree, 0)
}
fn has_form_control_descendant(node: &AXNode, tree: &AXTree, depth: usize) -> bool {
if depth > 8 {
return false;
}
for child_id in &node.child_ids {
if let Some(child) = tree.nodes.get(child_id) {
let role = child.role.as_deref().unwrap_or("").to_lowercase();
if is_form_input(&role) {
return true;
}
if has_form_control_descendant(child, tree, depth + 1) {
return true;
}
}
}
false
}
fn has_group_label(node: &AXNode) -> bool {
if let Some(name) = &node.name {
if !name.trim().is_empty() {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXProperty, AXValue};
fn create_input(id: &str, role: &str, name: Option<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
fn create_input_with_required(
id: &str,
role: &str,
name: Option<&str>,
required: bool,
) -> AXNode {
let mut node = create_input(id, role, name);
if required {
node.properties.push(AXProperty {
name: "required".to_string(),
value: AXValue::Bool(true),
});
}
node
}
#[test]
fn test_instructions_rule_metadata() {
assert_eq!(INSTRUCTIONS_RULE.id, "3.3.2");
assert_eq!(INSTRUCTIONS_RULE.level, WcagLevel::A);
}
#[test]
fn test_is_form_input() {
assert!(is_form_input("textbox"));
assert!(is_form_input("checkbox"));
assert!(is_form_input("combobox"));
assert!(!is_form_input("button"));
assert!(!is_form_input("link"));
}
#[test]
fn test_input_without_label() {
let tree = AXTree::from_nodes(vec![create_input("1", "textbox", None)]);
let results = check_instructions(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("no accessible label")));
}
#[test]
fn test_input_with_label() {
let tree = AXTree::from_nodes(vec![create_input("1", "textbox", Some("Email address"))]);
let results = check_instructions(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("no accessible label")));
}
#[test]
fn test_required_without_indication() {
let tree = AXTree::from_nodes(vec![create_input_with_required(
"1",
"textbox",
Some("Name"),
true,
)]);
let results = check_instructions(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("Required field")));
}
#[test]
fn test_required_with_indication() {
let tree = AXTree::from_nodes(vec![create_input_with_required(
"1",
"textbox",
Some("Name (required)"),
true,
)]);
let results = check_instructions(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("Required field not clearly indicated")));
}
fn group_with_html_tag(id: &str, tag: &str, name: Option<&str>) -> AXNode {
let mut n = create_input(id, "group", name);
n.properties.push(AXProperty {
name: "htmlTag".into(),
value: AXValue::String(tag.into()),
});
n
}
#[test]
fn test_details_not_flagged_as_form_group() {
let tree = AXTree::from_nodes(vec![group_with_html_tag("1", "DETAILS", None)]);
let results = check_instructions(&tree);
assert!(
!results
.violations
.iter()
.any(|v| v.message.contains("Form group has no legend")),
"<details> must not be flagged as a form group missing a legend"
);
}
#[test]
fn test_address_not_flagged_as_form_group() {
let tree = AXTree::from_nodes(vec![group_with_html_tag("1", "ADDRESS", None)]);
let results = check_instructions(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("Form group has no legend")));
}
#[test]
fn test_fieldset_without_legend_still_flagged() {
let tree = AXTree::from_nodes(vec![group_with_html_tag("1", "FIELDSET", None)]);
let results = check_instructions(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("Form group has no legend")));
}
#[test]
fn test_generic_group_with_form_control_flagged() {
let mut group = create_input("g", "group", None);
group.child_ids.push("input".into());
let input = create_input("input", "textbox", Some("Email"));
let tree = AXTree::from_nodes(vec![group, input]);
let results = check_instructions(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("Form group has no legend")));
}
}