auditmysite 1.7.3

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
//! WCAG 2.1.1 Keyboard — detect `onclick` on non-interactive elements
//!
//! Pages that attach click handlers to `<div>`, `<span>`, `<p>`, or `<li>`
//! without making them keyboard-operable leave a subset of users unable to
//! activate the element. This check inspects the DOM for inline `onclick`
//! attributes on non-interactive tags without a corresponding `role` or
//! `tabindex`.
//!
//! Note: this only catches inline `onclick=` attributes. JS-attached listeners
//! (`addEventListener('click', ...)`) are not visible from CSS/DOM inspection
//! and remain a manual-review concern.

use chromiumoxide::Page;

use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};

pub(super) const CLICK_HANDLERS_RULE: RuleMetadata = RuleMetadata {
    id: "2.1.1",
    name: "Keyboard",
    level: WcagLevel::A,
    severity: Severity::High,
    description: "Click handlers on non-interactive elements must be keyboard-accessible",
    help_url: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
    axe_id: "click-events-have-key-events",
    tags: &["wcag2a", "wcag211", "cat.keyboard"],
};

// Scans the DOM for non-interactive elements carrying an inline onclick attribute
// without a focusable role or tabindex. Returns up to 10 affected selectors.
const CLICK_HANDLERS_JS: &str = r#"
(function() {
  /*CSS_SELECTOR*/
  const NON_INTERACTIVE = ['div', 'span', 'p', 'li', 'section', 'article'];
  const INTERACTIVE_ROLES = new Set([
    'button', 'link', 'checkbox', 'radio', 'switch',
    'menuitem', 'menuitemcheckbox', 'menuitemradio',
    'tab', 'option', 'treeitem'
  ]);
  const findings = [];
  try {
    const all = document.querySelectorAll('[onclick]');
    for (const el of Array.from(all)) {
      const tag = el.tagName.toLowerCase();
      if (!NON_INTERACTIVE.includes(tag)) continue;
      const role = (el.getAttribute('role') || '').toLowerCase();
      if (INTERACTIVE_ROLES.has(role)) continue;
      const tabindex = el.getAttribute('tabindex');
      const focusable = tabindex !== null && parseInt(tabindex, 10) >= 0;
      if (focusable) continue;
      __amsPush(findings, el, __amsCssSelector(el), 10);
      if (__amsReal(findings) >= 10) break;
    }
  } catch(e) {}
  return { count: findings.length, selectors: findings };
})()
"#;

pub async fn check_click_handlers_with_page(page: &Page) -> Vec<Violation> {
    let val = match crate::wcag::types::evaluate_or_fail(
        page,
        &CLICK_HANDLERS_RULE,
        &CLICK_HANDLERS_JS.replace(
            "/*CSS_SELECTOR*/",
            crate::accessibility::js_helpers::CSS_SELECTOR_JS,
        ),
    )
    .await
    {
        Ok(v) => v,
        Err(violations) => return violations,
    };

    let count = val.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
    if count == 0 {
        return vec![];
    }

    let selectors: Vec<String> = val
        .get("selectors")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|s| s.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    selectors
        .into_iter()
        .map(|sel| {
            Violation::new(
                CLICK_HANDLERS_RULE.id,
                CLICK_HANDLERS_RULE.name,
                CLICK_HANDLERS_RULE.level,
                Severity::High,
                format!(
                    "Element with onclick handler on non-interactive tag has no keyboard equivalent ({sel})."
                ),
                &sel,
            )
            .with_selector(&sel)
            .with_fix(
                "Replace with a native interactive element (`<button>`, `<a>`), or add `role=\"button\"` + `tabindex=\"0\"` + a keyboard handler (`onkeydown`/`onkeyup`).",
            )
            .with_rule_id(CLICK_HANDLERS_RULE.axe_id)
            .with_help_url(CLICK_HANDLERS_RULE.help_url)
        })
        .collect()
}