use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};
pub const PAUSE_STOP_HIDE_RULE: RuleMetadata = RuleMetadata {
id: "2.2.2",
name: "Pause, Stop, Hide",
level: WcagLevel::A,
severity: Severity::Medium,
description: "Auto-starting moving, blinking, or scrolling content that lasts more than 5 seconds can be paused, stopped, or hidden",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html",
axe_id: "pause-stop-hide",
tags: &["wcag2a", "wcag222", "cat.time-and-media"],
};
const MIN_INFINITE_ANIMATION_SECONDS: f64 = 5.0;
#[derive(Debug, Clone)]
struct AnimatedElement {
selector: String,
duration_seconds: f64,
}
fn decide(
marquee_count: usize,
infinite_animations: &[AnimatedElement],
has_pause_control: bool,
respects_reduced_motion: bool,
) -> (bool, Vec<AnimatedElement>) {
let marquee_fires = marquee_count > 0 && !has_pause_control;
let animations_fire = if has_pause_control || respects_reduced_motion {
Vec::new()
} else {
infinite_animations.to_vec()
};
(marquee_fires, animations_fire)
}
const PAUSE_STOP_HIDE_JS: &str = r#"
(function() {
var PAUSE_CONTROL_PATTERN = /\b(pause|stop|anhalten|stopp|bewegung|animation)\b/i;
function isPlausiblePauseControl(el) {
var text = (el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent || '').trim();
return PAUSE_CONTROL_PATTERN.test(text);
}
var hasPauseControl = false;
var controls = document.querySelectorAll('button, [role="button"], a[href]');
for (var c = 0; c < controls.length && !hasPauseControl; c++) {
if (isPlausiblePauseControl(controls[c])) hasPauseControl = true;
}
var marqueeCount = document.querySelectorAll('marquee').length;
var respectsReducedMotion = false;
try {
for (const sheet of Array.from(document.styleSheets)) {
let rules;
try { rules = Array.from(sheet.cssRules || []); }
catch (e) { continue; }
const walk = (rs) => {
for (const r of rs) {
if (r.type === CSSRule.MEDIA_RULE) {
const condition = (r.conditionText || (r.media && r.media.mediaText) || '').toLowerCase();
if (condition.includes('prefers-reduced-motion')) {
respectsReducedMotion = true;
}
if (r.cssRules) walk(Array.from(r.cssRules));
}
}
};
walk(rules);
}
} catch (e) {}
function parseMaxSeconds(value) {
var parts = (value || '').split(',');
var max = 0;
for (var i = 0; i < parts.length; i++) {
var v = parts[i].trim();
var seconds = 0;
if (v.endsWith('ms')) seconds = parseFloat(v) / 1000;
else if (v.endsWith('s')) seconds = parseFloat(v);
if (!isNaN(seconds) && seconds > max) max = seconds;
}
return max;
}
var animated = [];
var all = document.querySelectorAll('*');
for (var i = 0; i < all.length && animated.length < 5; i++) {
var el = all[i];
var cs = getComputedStyle(el);
var iterationCount = cs.animationIterationCount || '';
if (iterationCount.indexOf('infinite') === -1) continue;
var duration = parseMaxSeconds(cs.animationDuration);
if (duration <= 0) continue;
animated.push({
selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
duration_seconds: duration
});
}
// Only explicit autoplay/update signals are considered. Library class names
// that merely indicate a carousel exists are deliberately insufficient.
var autoWidgets = [];
var widgetSelectors = '[data-autoplay="true"], [data-ride="carousel"], [data-bs-ride="carousel"], .swiper-autoplay-running, [data-update-interval]';
for (const widget of document.querySelectorAll(widgetSelectors)) {
const interval = parseFloat(widget.getAttribute('data-interval') || widget.getAttribute('data-bs-interval') || widget.getAttribute('data-update-interval') || '0');
const explicitRunning = widget.matches('.swiper-autoplay-running, [data-autoplay="true"], [data-ride="carousel"], [data-bs-ride="carousel"]');
if (!explicitRunning && interval <= 5000) continue;
autoWidgets.push({
selector: widget.tagName.toLowerCase() + (widget.id ? '#' + widget.id : ''),
interval_ms: interval || null
});
if (autoWidgets.length >= 5) break;
}
return {
marquee_count: marqueeCount,
has_pause_control: hasPauseControl,
respects_reduced_motion: respectsReducedMotion,
animated: animated,
auto_widgets: autoWidgets
};
})()
"#;
pub async fn check_pause_stop_hide_with_page(page: &Page) -> Vec<Violation> {
let val =
match crate::wcag::types::evaluate_or_fail(page, &PAUSE_STOP_HIDE_RULE, PAUSE_STOP_HIDE_JS)
.await
{
Ok(v) => v,
Err(violations) => return violations,
};
let marquee_count = val
.get("marquee_count")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let has_pause_control = val
.get("has_pause_control")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let respects_reduced_motion = val
.get("respects_reduced_motion")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let infinite_animations: Vec<AnimatedElement> = val
.get("animated")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let selector = entry.get("selector").and_then(|v| v.as_str())?.to_string();
let duration_seconds =
entry.get("duration_seconds").and_then(|v| v.as_f64())?;
if duration_seconds <= MIN_INFINITE_ANIMATION_SECONDS {
return None;
}
Some(AnimatedElement {
selector,
duration_seconds,
})
})
.collect()
})
.unwrap_or_default();
let (marquee_fires, animations_fire) = decide(
marquee_count,
&infinite_animations,
has_pause_control,
respects_reduced_motion,
);
let mut violations = Vec::new();
if !has_pause_control {
if let Some(widgets) = val.get("auto_widgets").and_then(|value| value.as_array()) {
for widget in widgets {
let Some(selector) = widget.get("selector").and_then(|value| value.as_str()) else {
continue;
};
violations.push(
Violation::new(
PAUSE_STOP_HIDE_RULE.id,
PAUSE_STOP_HIDE_RULE.name,
PAUSE_STOP_HIDE_RULE.level,
PAUSE_STOP_HIDE_RULE.severity,
"An explicitly auto-running carousel or updating widget was detected without a recognizable pause, stop, or hide control.",
selector,
)
.with_selector(selector)
.with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
.with_kind(crate::wcag::types::Outcome::Review)
.with_fix("Provide a visible pause, stop, or hide control next to the auto-running content")
.with_help_url(PAUSE_STOP_HIDE_RULE.help_url),
);
}
}
}
if marquee_fires {
violations.push(
Violation::new(
PAUSE_STOP_HIDE_RULE.id,
PAUSE_STOP_HIDE_RULE.name,
PAUSE_STOP_HIDE_RULE.level,
PAUSE_STOP_HIDE_RULE.severity,
format!(
"Page uses {} legacy `<marquee>` element(s), which scroll automatically and \
indefinitely with no native pause, stop, or hide control, and no other \
pause control was detected on the page.",
marquee_count
),
"marquee",
)
.with_selector("marquee")
.with_fix(
"Replace `<marquee>` with a modern implementation (CSS animation or a small \
script) that includes a visible pause/stop button, or remove the automatic \
scrolling entirely.",
)
.with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
.with_help_url(PAUSE_STOP_HIDE_RULE.help_url)
.as_warning(),
);
}
if !animations_fire.is_empty() {
let sample = animations_fire
.iter()
.take(3)
.map(|a| format!("{} ({:.1}s/cycle)", a.selector, a.duration_seconds))
.collect::<Vec<_>>()
.join(", ");
violations.push(
Violation::new(
PAUSE_STOP_HIDE_RULE.id,
PAUSE_STOP_HIDE_RULE.name,
PAUSE_STOP_HIDE_RULE.level,
PAUSE_STOP_HIDE_RULE.severity,
format!(
"{} element(s) run a CSS animation with `animation-iteration-count: infinite` \
and a per-cycle duration over {}s ({}), with no pause/stop control and no \
`prefers-reduced-motion` handling detected on the page.",
animations_fire.len(),
MIN_INFINITE_ANIMATION_SECONDS,
sample
),
&animations_fire[0].selector,
)
.with_selector(&animations_fire[0].selector)
.with_fix(
"Add a visible pause/stop control for the animation, or wrap it in \
`@media (prefers-reduced-motion: reduce) { animation: none; }` so users who need \
to stop it can.",
)
.with_rule_id(PAUSE_STOP_HIDE_RULE.axe_id)
.with_help_url(PAUSE_STOP_HIDE_RULE.help_url)
.as_warning(),
);
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
fn animation(selector: &str, seconds: f64) -> AnimatedElement {
AnimatedElement {
selector: selector.to_string(),
duration_seconds: seconds,
}
}
#[test]
fn fires_for_marquee_with_no_pause_control() {
let (marquee_fires, animations_fire) = decide(1, &[], false, false);
assert!(marquee_fires);
assert!(animations_fire.is_empty());
}
#[test]
fn fires_for_long_infinite_animation_with_no_pause_control() {
let animations = vec![animation("div.ticker", 8.0)];
let (marquee_fires, animations_fire) = decide(0, &animations, false, false);
assert!(!marquee_fires);
assert_eq!(animations_fire.len(), 1);
}
#[test]
fn does_not_fire_when_pause_control_detected() {
let animations = vec![animation("div.ticker", 8.0)];
let (marquee_fires, animations_fire) = decide(1, &animations, true, false);
assert!(!marquee_fires);
assert!(animations_fire.is_empty());
}
#[test]
fn does_not_fire_for_animation_when_reduced_motion_is_respected() {
let animations = vec![animation("div.ticker", 8.0)];
let (marquee_fires, animations_fire) = decide(0, &animations, false, true);
assert!(!marquee_fires);
assert!(animations_fire.is_empty());
}
#[test]
fn marquee_still_fires_when_reduced_motion_is_respected() {
let (marquee_fires, animations_fire) = decide(1, &[], false, true);
assert!(marquee_fires);
assert!(animations_fire.is_empty());
}
}