chore(a11y): add lint rule flagging icon-only buttons without accessible name

BaseButton renders a bare button and does not enforce a name, so icon-only
usages without aria-label are announced as just "button" — the a11y audit
found this pattern regenerating across the codebase (42 current instances,
including the TipTap bubble menu where v-tooltip provides no accessible
name). The new vikunja/icon-button-accessible-name rule flags icon-only
BaseButton/XButton usages that have no aria-label, aria-labelledby, title,
visible text, or sr-only text; unknown child components are assumed to
render text to avoid false positives.

Set to 'warn' until the existing instances get aria-labels, then raise to
'error'.

Claude-Session: https://claude.ai/code/session_018prq5WqRL38EGhPNQKqZUP
This commit is contained in:
kolaente
2026-07-18 14:05:36 +00:00
committed by kolaente
parent f405fc5a78
commit 1d17e4cacc
2 changed files with 129 additions and 0 deletions
@@ -0,0 +1,118 @@
/**
* Flags icon-only buttons without an accessible name.
*
* BaseButton renders a bare <button>/<a> and does not enforce a name, so an
* icon-only usage without aria-label is announced as just "button" (WCAG 1.1.1
* / 4.1.2). Same for XButton's icon-only mode. Visible text, {{ }} content,
* sr-only spans, aria-label(ledby) or title all count as a name; unknown child
* components are assumed to render text so the rule only fires on clearly
* icon-only content.
*/
const NAME_ATTRS = new Set(['aria-label', 'aria-labelledby', 'title'])
const ICON_ELEMENTS = new Set(['Icon', 'icon', 'svg', 'i'])
// Plain containers we can safely look through for text
const TRANSPARENT_ELEMENTS = new Set(['span', 'div', 'template'])
function attrName(attr) {
if (!attr.directive) {
return attr.key.rawName ?? attr.key.name
}
if (attr.key.name.name === 'bind' && attr.key.argument?.type === 'VIdentifier') {
return attr.key.argument.rawName ?? attr.key.argument.name
}
return null
}
function hasNameAttr(element, extra = []) {
return element.startTag.attributes.some(attr => {
if (attr.directive && ['text', 'html'].includes(attr.key.name?.name)) {
return true
}
const name = attrName(attr)
if (name === null || !(NAME_ATTRS.has(name) || extra.includes(name))) {
return false
}
// static attr must be non-empty; bound attrs are trusted
return attr.directive || (attr.value != null && attr.value.value.trim() !== '')
})
}
function getAttr(element, name) {
return element.startTag.attributes.find(attr => attrName(attr) === name)
}
function contentProvidesName(element) {
for (const child of element.children) {
if (child.type === 'VText' && child.value.trim() !== '') {
return true
}
if (child.type === 'VExpressionContainer') {
return true
}
if (child.type === 'VElement') {
if (child.rawName === 'slot' || child.rawName === 'component') {
return true
}
if (hasNameAttr(child, ['alt'])) {
return true
}
if (ICON_ELEMENTS.has(child.rawName)) {
continue
}
if (TRANSPARENT_ELEMENTS.has(child.rawName)) {
if (contentProvidesName(child)) {
return true
}
continue
}
// Unknown component — assume it renders text
return true
}
}
return false
}
export default {
meta: {
type: 'problem',
docs: {
description: 'require an accessible name on icon-only BaseButton/XButton usages',
},
messages: {
missingName: 'Icon-only <{{component}}> has no accessible name — screen readers announce it as just "button". Add aria-label (translated via $t) or visible/sr-only text.',
},
schema: [],
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
const services = sourceCode.parserServices
if (!services?.defineTemplateBodyVisitor) {
return {}
}
function check(node, component) {
if (hasNameAttr(node) || contentProvidesName(node)) {
return
}
context.report({
node: node.startTag,
messageId: 'missingName',
data: {component},
})
}
return services.defineTemplateBodyVisitor({
'VElement[rawName="BaseButton"]'(node) {
check(node, 'BaseButton')
},
'VElement[rawName="XButton"]'(node) {
// XButton is only icon-only when it has an icon prop and no slot content
if (getAttr(node, 'icon') === undefined) {
return
}
check(node, 'XButton')
},
})
},
}
+11
View File
@@ -4,6 +4,7 @@ import vueTsEslintConfig from '@vue/eslint-config-typescript'
import pluginDepend from 'eslint-plugin-depend'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import iconButtonAccessibleName from './eslint-rules/icon-button-accessible-name.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -18,7 +19,17 @@ export default [
],
},
{
plugins: {
vikunja: {
rules: {
'icon-button-accessible-name': iconButtonAccessibleName,
},
},
},
rules: {
// TODO(a11y): raise to 'error' once the 42 existing unnamed icon buttons have aria-labels (ACR follow-up)
'vikunja/icon-button-accessible-name': 'warn',
'quotes': ['error', 'single'],
'comma-dangle': ['error', 'always-multiline'],
'semi': ['error', 'never'],