use crate::core::{Element, Node};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
Banner,
Navigation,
Main,
Complementary,
Contentinfo,
Search,
Form,
Region,
Button,
Dialog,
Alert,
Status,
Custom(String),
}
impl Role {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Banner => "banner",
Self::Navigation => "navigation",
Self::Main => "main",
Self::Complementary => "complementary",
Self::Contentinfo => "contentinfo",
Self::Search => "search",
Self::Form => "form",
Self::Region => "region",
Self::Button => "button",
Self::Dialog => "dialog",
Self::Alert => "alert",
Self::Status => "status",
Self::Custom(role) => role,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct A11yIssue {
pub tag: String,
pub message: String,
}
#[must_use]
pub fn audit(node: &Node) -> Vec<A11yIssue> {
let mut issues = Vec::new();
walk(node, &mut issues);
issues
}
fn walk(node: &Node, issues: &mut Vec<A11yIssue>) {
match node {
Node::Element(element) => {
check(element, issues);
for child in element.children() {
walk(child, issues);
}
}
Node::Fragment(children) => {
for child in children {
walk(child, issues);
}
}
Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
}
}
fn check(element: &Element, issues: &mut Vec<A11yIssue>) {
let has = |key: &str| element.attributes().iter().any(|a| a.key() == key);
let mut report = |message: &str| {
issues.push(A11yIssue {
tag: element.tag().to_string(),
message: message.to_string(),
});
};
match element.tag() {
"img" if !has("alt") => {
report("an image needs an alt attribute; pass an empty one if it is decorative");
}
"iframe" if !has("title") => {
report("an iframe needs a title describing its content");
}
"button" if !has_accessible_name(element) => {
report("a button needs text content or an aria-label");
}
"a" if !has_accessible_name(element) => {
report("a link needs text content or an aria-label");
}
"input" if !has("aria-label") && !has("id") && !has("aria-labelledby") => {
report("an input needs an id a label can point at, or an aria-label");
}
_ => {}
}
}
fn has_accessible_name(element: &Element) -> bool {
if element
.attributes()
.iter()
.any(|a| a.key() == "aria-label" || a.key() == "aria-labelledby")
{
return true;
}
if element.content().is_some_and(|c| !c.trim().is_empty()) {
return true;
}
element.children().iter().any(|child| !child.is_empty())
}
pub fn debug_assert_accessible(node: &Node) {
if cfg!(debug_assertions) {
let issues = audit(node);
assert!(
issues.is_empty(),
"accessibility audit found {} issue(s): {:?}",
issues.len(),
issues
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elements::{a, button, div, iframe, iframe_titled, image, img, input, link_to};
#[test]
fn an_image_without_alt_is_reported() {
let issues = audit(&img().attr("src", "/a.png").into());
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].tag, "img");
}
#[test]
fn an_image_with_an_empty_alt_is_accepted_as_decorative() {
assert!(audit(&image("/a.png", "").into()).is_empty());
}
#[test]
fn an_iframe_without_a_title_is_reported() {
assert_eq!(audit(&iframe().attr("src", "/e").into()).len(), 1);
assert!(audit(&iframe_titled("/e", "A map").into()).is_empty());
}
#[test]
fn a_button_needs_text_or_a_label() {
assert_eq!(audit(&button().into()).len(), 1);
assert!(audit(&button().text("Send").into()).is_empty());
assert!(audit(&button().aria_attr("label", "Send").into()).is_empty());
}
#[test]
fn a_link_needs_an_accessible_name() {
assert_eq!(audit(&a().attr("href", "/x").into()).len(), 1);
assert!(audit(&link_to("/x").text("Home").into()).is_empty());
assert!(audit(&link_to("/x").child(image("/i.png", "Home")).into()).is_empty());
}
#[test]
fn an_input_needs_something_a_label_can_attach_to() {
assert_eq!(audit(&input().attr("type", "email").into()).len(), 1);
assert!(audit(&input().attr("id", "email").into()).is_empty());
assert!(audit(&input().aria_attr("label", "E-mail").into()).is_empty());
}
#[test]
fn the_audit_descends_into_children_and_fragments() {
let tree = div().child(div().child(img().attr("src", "/a.png")));
assert_eq!(audit(&tree.into()).len(), 1);
let fragment = Node::fragment([img().attr("src", "/a.png").into(), button().into()]);
assert_eq!(audit(&fragment).len(), 2);
}
#[test]
fn a_clean_page_reports_nothing() {
let tree = div()
.child(image("/a.png", "A cat"))
.child(button().text("Send"))
.child(link_to("/x").text("Home"));
assert!(audit(&tree.into()).is_empty());
}
#[test]
fn roles_render_their_attribute_value() {
assert_eq!(Role::Navigation.as_str(), "navigation");
assert_eq!(Role::Custom("tooltip".into()).as_str(), "tooltip");
}
}