chore(eslint): warn on bare outline-none without focus replacement

This commit is contained in:
Danny White committed 2026-07-22 15:45:35 -04:00
1 parent fac534d2ca
commit 8f7a080f09
7 files changed
+329 -3

No files matched your search

+14 -1
View File
@@ -21,7 +21,8 @@
"jsx-a11y/heading-has-content": 2,
"jsx-a11y/no-distracting-elements": 0,
"valtio/state-snapshot-rule": 1,
"react-hook-form/no-use-watch": 76
"react-hook-form/no-use-watch": 76,
"supabase/require-focus-visible-outline": 11
},
"ruleFiles": {
"react-hooks/exhaustive-deps": {
@@ -1111,6 +1112,18 @@
"components/interfaces/Storage/EditBucketModal.tsx": 2,
"components/interfaces/Storage/StorageSettings/StorageSettings.tsx": 1,
"components/interfaces/Support/LinkSupportTicketForm.tsx": 1
},
"supabase/require-focus-visible-outline": {
"components/grid/components/editor/DateTimeEditor.tsx": 1,
"components/interfaces/APIKeys/APIKeyRow.tsx": 1,
"components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx": 1,
"components/interfaces/Integrations/CronJobs/HttpBodyFieldSection.tsx": 1,
"components/interfaces/Observability/ServiceHealthTable.tsx": 1,
"components/interfaces/Organization/PrivateApps/Apps/CreateAppSheet/CreateAppSheet.tsx": 1,
"components/layouts/Navigation/LayoutHeader/BreadcrumbsView.tsx": 1,
"components/ui/AIAssistantPanel/AIAssistant.tsx": 1,
"components/ui/DataTable/primitives/InputWithAddons.tsx": 2,
"components/ui/EditorPanel/EditorPanel.tsx": 1
}
}
}
+2 -1
View File
@@ -20,5 +20,6 @@
"jsx-a11y/heading-has-content",
"jsx-a11y/no-distracting-elements",
"valtio/state-snapshot-rule",
"react-hook-form/no-use-watch"
"react-hook-form/no-use-watch",
"supabase/require-focus-visible-outline"
]
+3
View File
@@ -10,6 +10,7 @@ const tsparser = require('@typescript-eslint/parser')
// Custom Supabase rules
const noAwaitBeforeCopyToClipboard = require('./rules/no-await-before-copy-to-clipboard')
const requireExplicitTabIndex = require('./rules/require-explicit-tabindex')
const requireFocusVisibleOutline = require('./rules/require-focus-visible-outline')
const compat = new FlatCompat({
baseDirectory: __dirname,
@@ -22,6 +23,7 @@ const supabasePlugin = {
rules: {
'no-await-before-copy-to-clipboard': noAwaitBeforeCopyToClipboard,
'require-explicit-tabindex': requireExplicitTabIndex,
'require-focus-visible-outline': requireFocusVisibleOutline,
},
}
@@ -44,6 +46,7 @@ const typescriptConfig = {
'@typescript-eslint/no-explicit-any': 'warn',
'supabase/no-await-before-copy-to-clipboard': 'error',
'supabase/require-explicit-tabindex': 'error',
'supabase/require-focus-visible-outline': 'warn',
},
}
+3 -1
View File
@@ -5,7 +5,8 @@
"license": "MIT",
"scripts": {
"preinstall": "npx only-allow pnpm",
"clean": "rimraf .turbo tsconfig.tsbuildinfo"
"clean": "rimraf .turbo tsconfig.tsbuildinfo",
"test": "node rules/require-focus-visible-outline.test.js"
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.0",
@@ -17,6 +18,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-config-turbo": "^2.5.0",
"@typescript/native": "catalog:",
"eslint": "^9.0.0",
"typescript": "catalog:"
}
}
@@ -0,0 +1,225 @@
/**
* ESLint rule banning bare `outline-none` / `outline-hidden` Tailwind classes.
*
* Removing the default focus outline without a visible replacement hides keyboard
* focus from sighted users. Prefer the shared `focus-ring` or `focus-inset`
* utilities (or an explicit ring/outline on :focus-visible) so interactive
* controls stay visible when tabbing.
*
* BAD:
* <button className="outline-none">Save</button>
* <div className="outline-hidden hover:bg-accent">Item</div>
*
* GOOD:
* <button className="focus-ring outline-none">Save</button>
* <input className="focus-visible:outline-none focus-visible:ring-2" />
* <div className="focus-inset outline-hidden">Row</div>
*/
const CLASSNAME_UTILS = new Set(['cn', 'clsx', 'classNames', 'twMerge', 'cva'])
/** @param {import('estree').Node | null | undefined} node */
function extractClassStrings(node) {
/** @type {string[]} */
const strings = []
if (!node) return strings
if (node.type === 'Literal' && typeof node.value === 'string') {
strings.push(node.value)
return strings
}
if (node.type === 'TemplateLiteral') {
const staticParts = node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ')
if (staticParts.trim()) {
strings.push(staticParts)
}
for (const expression of node.expressions) {
strings.push(...extractClassStrings(expression))
}
return strings
}
if (node.type === 'CallExpression') {
const calleeName = getCalleeName(node.callee)
if (calleeName && CLASSNAME_UTILS.has(calleeName)) {
for (const arg of node.arguments) {
strings.push(...extractClassStrings(arg))
}
}
return strings
}
if (node.type === 'ConditionalExpression') {
strings.push(...extractClassStrings(node.consequent))
strings.push(...extractClassStrings(node.alternate))
return strings
}
if (node.type === 'LogicalExpression') {
strings.push(...extractClassStrings(node.left))
strings.push(...extractClassStrings(node.right))
return strings
}
if (node.type === 'ArrayExpression') {
for (const element of node.elements) {
if (element) {
strings.push(...extractClassStrings(element))
}
}
return strings
}
return strings
}
/** @param {import('estree').Node} callee */
function getCalleeName(callee) {
if (callee.type === 'Identifier') {
return callee.name
}
if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
return callee.property.name
}
return null
}
/** @param {import('estree').Node} attr */
function getClassNameStrings(attr) {
if (attr.type !== 'JSXAttribute') return []
if (attr.name.type !== 'JSXIdentifier' || attr.name.name !== 'className') return []
const value = attr.value
if (!value) return []
if (value.type === 'Literal') {
return extractClassStrings(value)
}
if (value.type === 'JSXExpressionContainer') {
return extractClassStrings(value.expression)
}
return []
}
/** @param {string} classString */
function tokenizeClasses(classString) {
return classString.split(/\s+/).filter(Boolean)
}
/** @param {string} token */
function normalizeToken(token) {
return token.replace(/!+$/, '')
}
/** @param {string} token */
function isOutlineNoneOrHiddenToken(token) {
const normalized = normalizeToken(token)
return /(?:^|:)outline-(?:none|hidden)$/.test(normalized)
}
/** @param {string} classString */
function hasBareOutlineClass(classString) {
return tokenizeClasses(classString).some(isOutlineNoneOrHiddenToken)
}
/** @param {string} token */
function isApprovedFocusOutlineReplacement(token) {
const normalized = normalizeToken(token)
if (normalized === 'focus-ring' || normalized === 'focus-inset') {
return true
}
if (/focus-visible:ring-/.test(normalized)) {
return true
}
if (/focus:ring-/.test(normalized)) {
return true
}
if (/group-focus-visible:ring-/.test(normalized)) {
return true
}
if (/group-focus-visible:outline-(?!none(?:$|-)|hidden(?:$|-))/.test(normalized)) {
return true
}
if (/focus-visible:outline-(?!none(?:$|-)|hidden(?:$|-))/.test(normalized)) {
return true
}
if (/focus:outline-(?!none(?:$|-)|hidden(?:$|-))/.test(normalized)) {
return true
}
if (/has-\[[^\]]+\]:ring-/.test(normalized)) {
return true
}
if (/has-\[[^\]]*focus-visible:ring/.test(normalized)) {
return true
}
if (/has-\[:focus-visible\]/.test(normalized)) {
return true
}
return false
}
/** @param {string} classString */
function hasApprovedFocusReplacement(classString) {
if (/\bfocus-ring\b/.test(classString) || /\bfocus-inset\b/.test(classString)) {
return true
}
return tokenizeClasses(classString).some(isApprovedFocusOutlineReplacement)
}
/** @param {string} classString */
function isViolation(classString) {
if (!hasBareOutlineClass(classString)) {
return false
}
return !hasApprovedFocusReplacement(classString)
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow bare outline-none/outline-hidden without an approved focus-visible replacement',
recommended: true,
},
messages: {
bareOutlineNone:
'Avoid bare `outline-none` / `outline-hidden` without a keyboard focus indicator. Add `focus-ring` or `focus-inset`, or pair removal with `focus-visible:ring-*` / a non-none `focus-visible:outline-*`.',
},
schema: [],
},
create(context) {
return {
/** @param {import('estree').Node} node */
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'className') {
return
}
const classStrings = getClassNameStrings(node)
if (classStrings.length === 0) {
return
}
const combined = classStrings.join(' ')
if (!isViolation(combined)) {
return
}
context.report({
node,
messageId: 'bareOutlineNone',
})
},
}
},
}
@@ -0,0 +1,79 @@
/**
* Unit tests for require-focus-visible-outline.
*
* Run: node rules/require-focus-visible-outline.test.js
*/
const { RuleTester } = require('eslint')
const rule = require('./require-focus-visible-outline')
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
})
ruleTester.run('require-focus-visible-outline', rule, {
valid: [
{ code: '<button className="focus-ring outline-none">Save</button>' },
{ code: '<div className="focus-inset outline-hidden">Row</div>' },
{
code: '<input className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" />',
},
{ code: '<button className="focus:outline-hidden focus:ring-1">Legacy</button>' },
{
code: '<div className="group-focus-visible:ring-2 outline-none">Group</div>',
},
{
code: '<div className="has-[:focus-visible]:ring-2 outline-hidden">Input group</div>',
},
{
code: '<div className="has-[input:focus-visible]:ring-2 outline-none">Input group</div>',
},
{ code: '<button className="rounded-md px-2">No outline utility</button>' },
{
code: `
function Example() {
return <button className={cn('outline-none', 'focus-ring')}>Save</button>
}
`,
},
{
code: '<button className={`outline-none focus-ring`}>Save</button>',
},
],
invalid: [
{
code: '<button className="outline-none">Save</button>',
errors: [{ messageId: 'bareOutlineNone' }],
},
{
code: '<div className="outline-hidden hover:bg-accent">Item</div>',
errors: [{ messageId: 'bareOutlineNone' }],
},
{
code: '<button className="focus:outline-none">Save</button>',
errors: [{ messageId: 'bareOutlineNone' }],
},
{
code: `
function Example() {
return <button className={cn('outline-hidden', 'hover:bg-accent')}>Save</button>
}
`,
errors: [{ messageId: 'bareOutlineNone' }],
},
{
code: '<div className="[&>textarea]:outline-hidden!">Wrapper</div>',
errors: [{ messageId: 'bareOutlineNone' }],
},
],
})
console.log('require-focus-visible-outline tests passed')
+3
View File
@@ -2337,6 +2337,9 @@ importers:
'@typescript/native':
specifier: 'catalog:'
version: typescript@7.0.2
eslint:
specifier: ^9.0.0
version: 9.37.0(jiti@2.7.0)(supports-color@8.1.1)
eslint-config-next:
specifier: ^15.5.0
version: 15.5.4(eslint@9.37.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2)