use crate::context::LintContext;
use crate::diagnostic::Severity;
use crate::rule::{Rule, RuleCategory, RuleMeta};
use vize_relief::ast::{ElementNode, PropNode, SourceLocation};
static META: RuleMeta = RuleMeta {
name: "vue/use-unique-element-ids",
description: "Enforce unique element IDs using useId() instead of static literals",
category: RuleCategory::Accessibility,
fixable: false,
default_severity: Severity::Warning,
};
const ID_REFERENCE_ATTRIBUTES: &[&str] = &[
"for", "aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-activedescendant",
"aria-flowto",
"aria-details",
"aria-errormessage",
"headers", "list", "form", "popovertarget",
"anchor",
];
const ARIA_REFERENCE_ATTRIBUTES: &[&str] = &[
"aria-labelledby",
"aria-describedby",
"aria-controls",
"aria-owns",
"aria-activedescendant",
"aria-flowto",
"aria-details",
"aria-errormessage",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IdWarningTier {
FormRelated,
Landmark,
Other,
}
fn classify_element(tag: &str) -> IdWarningTier {
match tag {
"input" | "select" | "textarea" | "button" | "label" | "fieldset" | "output"
| "datalist" => IdWarningTier::FormRelated,
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "section" | "nav" | "article" | "aside"
| "main" | "header" | "footer" => IdWarningTier::Landmark,
_ => IdWarningTier::Other,
}
}
fn has_aria_reference_attr(element: &ElementNode) -> bool {
element.props.iter().any(|prop| {
if let PropNode::Attribute(attr) = prop {
ARIA_REFERENCE_ATTRIBUTES.contains(&attr.name.as_str())
} else {
false
}
})
}
pub struct UseUniqueElementIds {
pub allow_static: bool,
pub tiered: bool,
}
impl Default for UseUniqueElementIds {
fn default() -> Self {
Self {
allow_static: false,
tiered: true,
}
}
}
impl UseUniqueElementIds {
#[inline]
pub fn is_id_reference_attr(name: &str) -> bool {
ID_REFERENCE_ATTRIBUTES.contains(&name)
}
}
impl Rule for UseUniqueElementIds {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn enter_element<'a>(&self, ctx: &mut LintContext<'a>, element: &ElementNode<'a>) {
if self.allow_static {
return;
}
let tag = element.tag.as_str();
let tier = classify_element(tag);
for prop in &element.props {
if let PropNode::Attribute(attr) = prop {
let name = attr.name.as_str();
if name == "id" {
if let Some(value) = &attr.value {
if self.tiered {
match tier {
IdWarningTier::Landmark => {
continue;
}
IdWarningTier::FormRelated => {
self.report_static_id_tiered(
ctx,
&attr.loc,
value.content.as_str(),
false,
"message_form",
);
}
IdWarningTier::Other => {
if has_aria_reference_attr(element) {
self.report_static_id_tiered(
ctx,
&attr.loc,
value.content.as_str(),
false,
"message_aria_ref",
);
}
}
}
} else {
self.report_static_id(ctx, &attr.loc, value.content.as_str(), false);
}
}
}
else if Self::is_id_reference_attr(name) {
if let Some(value) = &attr.value {
self.report_static_id(ctx, &attr.loc, value.content.as_str(), true);
}
}
}
}
}
}
impl UseUniqueElementIds {
fn report_static_id(
&self,
ctx: &mut LintContext<'_>,
loc: &SourceLocation,
value: &str,
is_reference: bool,
) {
let has_use_id = ctx
.analysis()
.map(|a| a.bindings.contains("useId"))
.unwrap_or(false);
let message = if is_reference {
ctx.t_fmt(
"vue/use-unique-element-ids.message_reference",
&[("value", value)],
)
} else {
ctx.t_fmt("vue/use-unique-element-ids.message", &[("value", value)])
};
let help = if has_use_id {
ctx.t("vue/use-unique-element-ids.help_has_use_id")
} else {
ctx.t("vue/use-unique-element-ids.help")
};
ctx.warn_with_help(&message, loc, help);
}
fn report_static_id_tiered(
&self,
ctx: &mut LintContext<'_>,
loc: &SourceLocation,
value: &str,
is_reference: bool,
message_key_suffix: &str,
) {
let has_use_id = ctx
.analysis()
.map(|a| a.bindings.contains("useId"))
.unwrap_or(false);
let full_key = format!("vue/use-unique-element-ids.{message_key_suffix}");
let message = if is_reference {
ctx.t_fmt(
"vue/use-unique-element-ids.message_reference",
&[("value", value)],
)
} else {
ctx.t_fmt(&full_key, &[("value", value)])
};
let help = if has_use_id {
ctx.t("vue/use-unique-element-ids.help_has_use_id")
} else {
ctx.t("vue/use-unique-element-ids.help")
};
ctx.warn_with_help(&message, loc, help);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::Linter;
use crate::rule::RuleRegistry;
fn create_linter() -> Linter {
let mut registry = RuleRegistry::new();
registry.register(Box::new(UseUniqueElementIds::default()));
Linter::with_registry(registry)
}
fn create_linter_no_tiered() -> Linter {
let mut registry = RuleRegistry::new();
registry.register(Box::new(UseUniqueElementIds {
allow_static: false,
tiered: false,
}));
Linter::with_registry(registry)
}
#[test]
fn test_valid_dynamic_id() {
let linter = create_linter();
let result = linter.lint_template(r#"<div :id="id">content</div>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_valid_no_id() {
let linter = create_linter();
let result = linter.lint_template(r#"<div class="test">content</div>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_valid_dynamic_for() {
let linter = create_linter();
let result = linter.lint_template(
r#"<label :for="inputId">Name</label><input :id="inputId" />"#,
"test.vue",
);
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_skip_heading() {
let linter = create_linter();
let result = linter.lint_template(r#"<h1 id="intro">Introduction</h1>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_skip_section() {
let linter = create_linter();
let result = linter.lint_template(r#"<section id="about">About</section>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_skip_nav() {
let linter = create_linter();
let result = linter.lint_template(r#"<nav id="main-nav">Nav</nav>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_skip_footer() {
let linter = create_linter();
let result =
linter.lint_template(r#"<footer id="site-footer">Footer</footer>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_warn_input() {
let linter = create_linter();
let result = linter.lint_template(r#"<input id="name" type="text" />"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_tiered_warn_select() {
let linter = create_linter();
let result = linter.lint_template(r#"<select id="country"></select>"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_tiered_warn_label() {
let linter = create_linter();
let result = linter.lint_template(r#"<label id="name-label">Name</label>"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_tiered_skip_div_without_aria() {
let linter = create_linter();
let result = linter.lint_template(r#"<div id="panel">content</div>"#, "test.vue");
assert_eq!(result.warning_count, 0);
}
#[test]
fn test_tiered_warn_div_with_aria_ref() {
let linter = create_linter();
let result = linter.lint_template(
r#"<div id="panel" aria-labelledby="title">content</div>"#,
"test.vue",
);
assert_eq!(result.warning_count, 2);
}
#[test]
fn test_invalid_static_for() {
let linter = create_linter();
let result = linter.lint_template(r#"<label for="input">Name</label>"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_invalid_static_aria_labelledby() {
let linter = create_linter();
let result = linter.lint_template(
r#"<span role="checkbox" aria-labelledby="tac"></span>"#,
"test.vue",
);
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_invalid_static_aria_describedby() {
let linter = create_linter();
let result = linter.lint_template(r#"<input aria-describedby="hint" />"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_no_tiered_warns_heading() {
let linter = create_linter_no_tiered();
let result = linter.lint_template(r#"<h1 id="intro">Introduction</h1>"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_no_tiered_warns_div_without_aria() {
let linter = create_linter_no_tiered();
let result = linter.lint_template(r#"<div id="panel">content</div>"#, "test.vue");
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_no_tiered_invalid_multiple_static_ids() {
let linter = create_linter_no_tiered();
let result = linter.lint_template(
r#"<div id="foo"><label for="bar">Label</label><input id="bar" /></div>"#,
"test.vue",
);
assert_eq!(result.warning_count, 3);
}
#[test]
fn test_is_id_reference_attr() {
assert!(UseUniqueElementIds::is_id_reference_attr("for"));
assert!(UseUniqueElementIds::is_id_reference_attr("aria-labelledby"));
assert!(UseUniqueElementIds::is_id_reference_attr(
"aria-describedby"
));
assert!(!UseUniqueElementIds::is_id_reference_attr("id"));
assert!(!UseUniqueElementIds::is_id_reference_attr("class"));
}
#[test]
fn test_classify_element() {
assert_eq!(classify_element("input"), IdWarningTier::FormRelated);
assert_eq!(classify_element("select"), IdWarningTier::FormRelated);
assert_eq!(classify_element("label"), IdWarningTier::FormRelated);
assert_eq!(classify_element("h1"), IdWarningTier::Landmark);
assert_eq!(classify_element("section"), IdWarningTier::Landmark);
assert_eq!(classify_element("nav"), IdWarningTier::Landmark);
assert_eq!(classify_element("div"), IdWarningTier::Other);
assert_eq!(classify_element("span"), IdWarningTier::Other);
}
}