Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/rules/template-no-invalid-aria-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ Examples of **correct** code for this rule:
</template>
```

HTML custom elements (tags with a hyphen that start lowercase) are skipped —
their accessibility contracts are defined by the component author and cannot
be validated against the ARIA spec:

```gjs
<template>
<my-widget aria-bogus="x" />
</template>
```

## References

- [Using ARIA, Roles, States, and Properties](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques)
90 changes: 60 additions & 30 deletions lib/rules/template-no-invalid-aria-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ function isNumeric(value) {
return !Number.isNaN(Number(value));
}

/**
* Determine whether a tag name refers to an HTML custom element.
*
* Per the WHATWG HTML spec a valid custom element name must start with a
* lowercase ASCII letter and contain at least one hyphen.
* https://html.spec.whatwg.org/#valid-custom-element-name
*
* We do not attempt to enforce the full PCENChar grammar here — the lowercase
* + hyphen heuristic matches the behavior of angular-eslint's `valid-aria`
* rule and is sufficient to avoid false positives on custom elements whose
* a11y contracts ESLint cannot introspect.
*/
function isCustomElement(tag) {
return typeof tag === 'string' && tag.includes('-') && /^[a-z]/.test(tag);
}

function isValidAriaValue(attrName, value) {
const attrDef = aria.get(attrName);
if (!attrDef) {
Expand Down Expand Up @@ -115,44 +131,58 @@ module.exports = {
},

create(context) {
return {
GlimmerAttrNode(node) {
if (!node.name.startsWith('aria-')) {
return;
}
function checkAttr(attr) {
if (attr.type !== 'GlimmerAttrNode' || !attr.name?.startsWith('aria-')) {
return;
}

// Check for unknown ARIA attribute
if (!aria.has(node.name)) {
// Check for unknown ARIA attribute
if (!aria.has(attr.name)) {
context.report({
node: attr,
messageId: 'noInvalidAriaAttribute',
data: { attribute: attr.name },
});
return;
}

// Skip value validation for dynamic values (MustacheStatement, ConcatStatement)
if (
!attr.value ||
attr.value.type === 'GlimmerMustacheStatement' ||
attr.value.type === 'GlimmerConcatStatement'
) {
return;
}

// Validate value for text node values
if (attr.value.type === 'GlimmerTextNode') {
const value = attr.value.chars;
if (!isValidAriaValue(attr.name, value)) {
context.report({
node,
messageId: 'noInvalidAriaAttribute',
data: { attribute: node.name },
node: attr,
messageId: 'invalidAriaAttributeValue',
data: {
attribute: attr.name,
expectedType: getExpectedTypeDescription(attr.name),
},
});
return;
}
}
}

// Skip value validation for dynamic values (MustacheStatement, ConcatStatement)
if (
!node.value ||
node.value.type === 'GlimmerMustacheStatement' ||
node.value.type === 'GlimmerConcatStatement'
) {
return {
GlimmerElementNode(node) {
// Skip HTML custom elements (tags with a hyphen that start lowercase).
// Custom elements define their own a11y contracts that ESLint cannot
// introspect; their aria-* attributes may be valid against a shadow-
// DOM-mapped role. Matches angular-eslint's `valid-aria` behavior.
if (isCustomElement(node.tag)) {
return;
}

// Validate value for text node values
if (node.value.type === 'GlimmerTextNode') {
const value = node.value.chars;
if (!isValidAriaValue(node.name, value)) {
context.report({
node,
messageId: 'invalidAriaAttributeValue',
data: {
attribute: node.name,
expectedType: getExpectedTypeDescription(node.name),
},
});
}
for (const attr of node.attributes || []) {
checkAttr(attr);
}
},
};
Expand Down
Loading
Loading