use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};
pub(super) const ON_INPUT_RULE: RuleMetadata = RuleMetadata {
id: "3.2.2",
name: "On Input",
level: WcagLevel::A,
severity: Severity::Medium,
description: "Changing a setting does not automatically cause a change of context",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/on-input.html",
axe_id: "input-no-context-change",
tags: &["wcag2a", "wcag322", "cat.keyboard"],
};
const ON_INPUT_CAP: usize = 250;
const CONTEXT_CHANGE_PATTERNS: &[&str] = &[
"location=",
"location.href=",
"location.assign(",
"location.replace(",
"location.reload(",
"location.search=",
"location.pathname=",
".submit(",
".requestSubmit(",
"window.open(",
".focus(",
"navigate(",
];
const NAVIGATION_HINTS: &[&str] = &[
"language", "country", "region", "navigate", "redirect", "go to",
];
const ON_INPUT_BODY: &str = r#"
function accessibleName(el) {
var label = el.getAttribute('aria-label');
if (label && label.trim()) return label.trim();
var labelledby = el.getAttribute('aria-labelledby');
if (labelledby) {
var ref = document.getElementById(labelledby.split(/\s+/)[0]);
if (ref && ref.textContent.trim()) return ref.textContent.trim();
}
if (el.id) {
var forLabel = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
if (forLabel && forLabel.textContent.trim()) return forLabel.textContent.trim();
}
var parentLabel = el.closest('label');
if (parentLabel && parentLabel.textContent.trim()) return parentLabel.textContent.trim();
return (el.textContent || '').trim();
}
function isChangeControl(el) {
if (el.tagName === 'SELECT') return true;
var role = (el.getAttribute('role') || '').toLowerCase();
return role === 'combobox' || role === 'listbox';
}
// Inline handler source, plus the source of a global function it calls
// directly (onchange="applyFilter(this.value)"), so the Rust side can see
// what the handler does.
function handlerSource(el) {
var src = el.getAttribute('onchange');
if (src === null) return null;
var call = /^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(src);
if (call && typeof window[call[1]] === 'function') {
try { src += '\n' + Function.prototype.toString.call(window[call[1]]); } catch (e) {}
}
return src;
}
var buttonNames = [];
var buttons = document.querySelectorAll('button, input[type="submit"], [role="button"]');
for (var b = 0; b < buttons.length && buttonNames.length < CAP; b++) {
buttonNames.push(accessibleName(buttons[b]).toLowerCase());
}
var controls = [];
var nodes = document.querySelectorAll('select, [role="combobox"], [role="listbox"], input[type="radio"], [role="radio"]');
for (var i = 0; i < nodes.length && __amsReal(controls) < CAP; i++) {
var el = nodes[i];
var changeControl = isChangeControl(el);
__amsPush(controls, el, {
role: (el.getAttribute('role') || el.tagName.toLowerCase()),
name: accessibleName(el).toLowerCase(),
selector: __amsCssSelector(el),
change_control: changeControl,
handler: changeControl ? handlerSource(el) : null
}, CAP);
}
return { button_names: buttonNames, controls: controls };
"#;
#[derive(Debug, Clone, serde::Deserialize)]
struct Control {
role: String,
name: String,
selector: String,
change_control: bool,
handler: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct Scan {
button_names: Vec<String>,
controls: Vec<Control>,
}
const SUBMIT_WORDS: &[&str] = &[
"submit", "send", "go", "search", "absenden", "senden", "suchen",
];
fn has_submit_button(button_names: &[String]) -> bool {
button_names.iter().any(|name| {
name.split(|c: char| !c.is_alphanumeric())
.any(|word| SUBMIT_WORDS.contains(&word))
})
}
pub async fn check_on_input_with_page(page: &Page) -> Vec<Violation> {
let js = [
"(function() {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
&ON_INPUT_BODY.replace("CAP", &ON_INPUT_CAP.to_string()),
"})()",
]
.concat();
let val = match crate::wcag::types::evaluate_or_fail_for(
page,
"on-input",
crate::cli::WcagLevel::A,
js.as_str(),
)
.await
{
Ok(v) => v,
Err(violations) => return violations,
};
match serde_json::from_value::<Scan>(val) {
Ok(scan) => evaluate(&scan),
Err(_) => vec![],
}
}
fn changes_context(handler: &str) -> bool {
let code: String = handler.chars().filter(|c| !c.is_whitespace()).collect();
CONTEXT_CHANGE_PATTERNS.iter().any(|p| code.contains(p))
}
fn evaluate(scan: &Scan) -> Vec<Violation> {
let submit_button = has_submit_button(&scan.button_names);
scan.controls
.iter()
.filter_map(|c| {
let finding = |severity: Severity, message: String, fix: &str| {
Violation::new(
ON_INPUT_RULE.id,
ON_INPUT_RULE.name,
ON_INPUT_RULE.level,
severity,
message,
c.selector.clone(),
)
.with_selector(c.selector.clone())
.with_fix(fix)
.with_rule_id(ON_INPUT_RULE.axe_id)
.with_help_url(ON_INPUT_RULE.help_url)
};
if let Some(handler) = c.handler.as_deref().filter(|_| c.change_control) {
return Some(if changes_context(handler) {
finding(
Severity::Medium,
format!(
"{} element's onchange handler navigates, submits, opens a window or moves focus",
c.role
),
"Use a submit button instead of changing context on selection change",
)
} else {
finding(
Severity::Low,
format!(
"{} element has an onchange handler — verify that it only updates content and does not change context",
c.role
),
"If the handler navigates, submits or moves focus, add a submit button or tell users beforehand",
)
.as_warning()
});
}
if !submit_button && NAVIGATION_HINTS.iter().any(|h| c.name.contains(h)) {
return Some(
finding(
Severity::Low,
format!(
"{} '{}' may navigate on change without explicit submit — verify",
c.role, c.name
),
"If changing the selection navigates, add a submit button or notify users beforehand",
)
.as_warning(),
);
}
None
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wcag::types::Outcome;
fn control(name: &str, handler: Option<&str>) -> Control {
Control {
role: "select".to_string(),
name: name.to_string(),
selector: "select#x".to_string(),
change_control: true,
handler: handler.map(str::to_string),
}
}
fn scan(controls: Vec<Control>) -> Scan {
Scan {
button_names: Vec::new(),
controls,
}
}
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn submit_words_match_whole_words_only() {
for name in [
"go",
"go!",
"submit order",
"send message",
"search",
"jetzt absenden",
"suchen",
] {
assert!(has_submit_button(&names(&[name])), "{name}");
}
for name in [
"google",
"sign in with google",
"category",
"logo",
"resend-code",
"research",
"gold",
] {
assert!(!has_submit_button(&names(&[name])), "{name}");
}
}
#[test]
fn non_submit_buttons_do_not_suppress_navigation_hint() {
let mut s = scan(vec![control("language", None)]);
s.button_names = names(&["sign in with google", "category", "logo"]);
assert_eq!(evaluate(&s).len(), 1);
}
#[test]
fn navigating_handler_is_violation() {
for handler in [
"location.href=this.value",
"window.location = this.value",
"this.form.submit()",
"window.open(this.value)",
"go(this.value)\nfunction go(v) { document.location.assign(v); }",
] {
let v = evaluate(&scan(vec![control("pages", Some(handler))]));
assert_eq!(v.len(), 1, "{handler}");
assert_eq!(v[0].kind, Outcome::Fail, "{handler}");
assert_eq!(v[0].severity, Severity::Medium);
}
}
#[test]
fn content_updating_handler_is_review() {
let v = evaluate(&scan(vec![control(
"sort",
Some("grid.setSort(this.value)"),
)]));
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, Outcome::Review);
}
#[test]
fn filter_and_sort_selects_without_handler_are_not_reported() {
let v = evaluate(&scan(vec![
control("filter preset", None),
control("sort preset", None),
]));
assert!(v.is_empty());
}
#[test]
fn navigation_hint_without_handler_is_review() {
let v = evaluate(&scan(vec![control("language", None)]));
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, Outcome::Review);
}
#[test]
fn navigation_hint_with_submit_button_is_not_reported() {
let mut s = scan(vec![control("language", None)]);
s.button_names = names(&["go"]);
assert!(evaluate(&s).is_empty());
}
}