Skip to content
Merged
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
78 changes: 77 additions & 1 deletion lib/rules/template-no-redundant-role.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { roles } = require('aria-query');

const DEFAULT_CONFIG = {
checkAllHTMLElements: true,
};
Expand Down Expand Up @@ -37,6 +39,48 @@ const ALLOWED_ELEMENT_ROLES = [
{ name: 'input', role: 'combobox' },
];

// Per HTML-AAM, <select> maps to "combobox" only when neither `multiple` nor
// `size > 1` is set; otherwise it maps to "listbox". Mirrors jsx-a11y's
// src/util/implicitRoles/select.js.
//
// Returns 'combobox' / 'listbox' for static cases, or 'unknown' when a
// dynamic `multiple` or `size` value blocks a decision. Callers should skip
// flagging on 'unknown' to avoid false positives.
function getSelectImplicitRole(node) {
const attrs = node.attributes || [];
const multipleAttr = attrs.find((a) => a.name === 'multiple');
if (multipleAttr) {
// Valueless `multiple` or static string value — statically present.
if (!multipleAttr.value || multipleAttr.value.type === 'GlimmerTextNode') {
return 'listbox';
}
// Dynamic `multiple={{...}}` — Ember omits bound boolean attributes at
// runtime when the value is falsy, so we can't tell statically whether
// the implicit role is combobox or listbox.
return 'unknown';
}
const sizeAttr = attrs.find((a) => a.name === 'size');
if (sizeAttr) {
// Valueless `size` (e.g. `<select size>`) — per HTML boolean-attr
// semantics the attribute value is an empty string, which Number()
// parses as 0. Per HTML's default size (>1 → listbox), 0 leaves the
// implicit role as combobox. Treat the same as the static-0 case.
if (!sizeAttr.value) {
return 'combobox';
}
if (sizeAttr.value.type !== 'GlimmerTextNode') {
// Dynamic `size={{...}}` / concat — can't tell whether the runtime
// value is >1 or not, so bail out instead of risking a false positive.
return 'unknown';
}
const sizeValue = Number(sizeAttr.value.chars);
if (Number.isFinite(sizeValue) && sizeValue > 1) {
return 'listbox';
}
}
return 'combobox';
}

// Mapping of roles to their corresponding HTML elements
// From https://www.w3.org/TR/html-aria/
const ROLE_TO_ELEMENTS = {
Expand All @@ -45,6 +89,10 @@ const ROLE_TO_ELEMENTS = {
button: ['button'],
cell: ['td'],
checkbox: ['input'],
// <select> is a combobox by default per HTML-AAM (section 4). When
// `multiple` is present or `size > 1`, it maps to "listbox" instead;
// that case is handled at the call site via getSelectImplicitRole.
combobox: ['select'],
columnheader: ['th'],
complementary: ['aside'],
contentinfo: ['footer'],
Expand Down Expand Up @@ -125,7 +173,19 @@ module.exports = {

let roleValue;
if (roleAttr.value && roleAttr.value.type === 'GlimmerTextNode') {
roleValue = roleAttr.value.chars || '';
// ARIA role tokens are compared ASCII-case-insensitively, and the
// attribute is a space-separated fallback list. Per WAI-ARIA §4.1,
// UAs walk tokens for the first role they RECOGNISE — unknown
// leading tokens are skipped, subsequent tokens are author-provided
// fallbacks. `role="xxyxyz button"` resolves to `button`;
// `role="tab button"` resolves to `tab` (recognised first, even
// though no implicit mapping — this rule then has nothing to flag).
const tokens = (roleAttr.value.chars || '').trim().toLowerCase().split(/\s+/u);
const firstRecognised = tokens.find((t) => t && roles.has(t));
if (!firstRecognised) {
return;
}
roleValue = firstRecognised;
} else {
// Skip dynamic role values
return;
Expand All @@ -138,9 +198,25 @@ module.exports = {

const elementsWithRole = ROLE_TO_ELEMENTS[roleValue];
if (!elementsWithRole) {
// Role is recognised by ARIA but has no implicit-element mapping
// in this table — nothing can be redundant.
return;
}

// <select>'s implicit role depends on attributes (HTML-AAM):
// - default (no `multiple`, `size` absent or <= 1) → "combobox"
// - `multiple` or `size` > 1 → "listbox"
// A role attribute is only redundant when it matches the element's
// computed implicit role. Guard both combobox and listbox against
// the opposite configuration, and bail when `size` is dynamic
// ('unknown') rather than risk a false positive.
if (node.tag === 'select' && (roleValue === 'combobox' || roleValue === 'listbox')) {
const implicit = getSelectImplicitRole(node);
if (implicit !== roleValue) {
return;
}
}

const isRedundant =
elementsWithRole.includes(node.tag) &&
!ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue);
Expand Down
92 changes: 92 additions & 0 deletions tests/lib/rules/template-no-redundant-role.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ ruleTester.run('template-no-redundant-role', rule, {
options: [{ checkAllHTMLElements: false }],
},
'<template><input role="combobox"></template>',
// <select multiple> has implicit role listbox, so combobox is not redundant.
'<template><select role="combobox" multiple></select></template>',
// <select size="5"> (size > 1) has implicit role listbox.
'<template><select role="combobox" size="5"></select></template>',
// Default <select> (no `multiple`, `size` absent or <= 1) has implicit
// role "combobox" — an explicit `role="listbox"` overrides to listbox
// and is NOT redundant.
'<template><select role="listbox"></select></template>',
'<template><select role="listbox" size="1"></select></template>',
// Dynamic `multiple={{...}}` — can't determine implicit role statically,
// so neither `role="combobox"` nor `role="listbox"` is flagged.
'<template><select role="combobox" multiple={{this.isMulti}}></select></template>',
'<template><select role="listbox" multiple={{this.isMulti}}></select></template>',

// Role-fallback: first recognised token wins. `role="tab button"` on
// <button> resolves to `tab` (non-redundant — button's implicit is
// `button`, not `tab`). WAI-ARIA §4.1 fallback-list semantics.
'<template><button role="tab button"></button></template>',
],
invalid: [
{
Expand All @@ -69,6 +87,45 @@ ruleTester.run('template-no-redundant-role', rule, {
},
],
},
// Non-landmark same-role redundancy — covered by jsx-a11y / vue-a11y too.
{
code: '<template><button role="button"></button></template>',
output: '<template><button></button></template>',
errors: [{ message: 'Use of redundant or invalid role: button on <button> detected.' }],
},
{
code: '<template><img role="img" /></template>',
output: '<template><img /></template>',
errors: [{ message: 'Use of redundant or invalid role: img on <img> detected.' }],
},
{
// Valueless `<select size>` — per HTML boolean-attr semantics, the
// attribute value is an empty string; Number('') is 0; 0 is NOT > 1,
// so the implicit role stays combobox. `role="combobox"` is therefore
// redundant and must be flagged.
code: '<template><select role="combobox" size></select></template>',
output: '<template><select size></select></template>',
errors: [
{
message: 'Use of redundant or invalid role: combobox on <select> detected.',
},
],
},
{
// Role-fallback: unknown leading token is skipped per ARIA §4.1.
// `role="xxyxyz button"` resolves to `button`, which IS redundant on
// <button>. Autofix drops the whole role attribute — the implicit
// `button` role is preserved natively, so runtime semantics are
// unchanged. Authors who wanted the `xxyxyz` fallback for some
// reason can opt out via eslint-disable.
code: '<template><button role="xxyxyz button"></button></template>',
output: '<template><button></button></template>',
errors: [
{
message: 'Use of redundant or invalid role: button on <button> detected.',
},
],
},
{
code: '<template><main role="main"></main></template>',
output: '<template><main></main></template>',
Expand Down Expand Up @@ -155,6 +212,17 @@ hbsRuleTester.run('template-no-redundant-role', rule, {
options: [{ checkAllHTMLElements: false }],
},
'<ul class="list" role="combobox"></ul>',
// <select> with `multiple` has implicit role "listbox", so role="combobox"
// is not redundant (it disagrees with the implicit role, but that is for
// other rules to catch — this rule only flags redundancy).
'<select role="combobox" multiple></select>',
// <select size="5"> (size > 1) has implicit role "listbox", same reasoning.
'<select role="combobox" size="5"></select>',
// Default <select> (no `multiple`, `size` absent or <= 1) has implicit
// role "combobox" — explicit role="listbox" overrides to listbox and is
// NOT redundant.
'<select role="listbox"></select>',
'<select role="listbox" size="1"></select>',
],
invalid: [
{
Expand Down Expand Up @@ -243,6 +311,30 @@ hbsRuleTester.run('template-no-redundant-role', rule, {
'<select name="color" id="color" multiple><option value="default-color">black</option></select>',
errors: [{ message: 'Use of redundant or invalid role: listbox on <select> detected.' }],
},
{
// <select> without `multiple` or `size` defaults to role "combobox".
code: '<select role="combobox"></select>',
output: '<select></select>',
errors: [{ message: 'Use of redundant or invalid role: combobox on <select> detected.' }],
},
{
// size="1" still defaults to combobox (only size > 1 flips to listbox).
code: '<select role="combobox" size="1"></select>',
output: '<select size="1"></select>',
errors: [{ message: 'Use of redundant or invalid role: combobox on <select> detected.' }],
},
{
// Case-insensitive match on <select>, combined with the implicit-role check.
code: '<select role="COMBOBOX"></select>',
output: '<select></select>',
errors: [{ message: 'Use of redundant or invalid role: combobox on <select> detected.' }],
},
{
// Case-insensitive matching — ARIA role tokens compare as ASCII-case-insensitive.
code: '<body role="DOCUMENT"></body>',
output: '<body></body>',
errors: [{ message: 'Use of redundant or invalid role: document on <body> detected.' }],
},
{
code: '<main role="main"></main>',
output: '<main></main>',
Expand Down
Loading