From 8f7a080f09efaf87f1d75dd404c9890c7abb4842 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:45:35 -0400
Subject: [PATCH] chore(eslint): warn on bare outline-none without focus
replacement
---
.../studio/.github/eslint-rule-baselines.json | 15 +-
apps/studio/scripts/ratchet-rules.json | 3 +-
packages/eslint-config-supabase/next.js | 3 +
packages/eslint-config-supabase/package.json | 4 +-
.../rules/require-focus-visible-outline.js | 225 ++++++++++++++++++
.../require-focus-visible-outline.test.js | 79 ++++++
pnpm-lock.yaml | 3 +
7 files changed, 329 insertions(+), 3 deletions(-)
create mode 100644 packages/eslint-config-supabase/rules/require-focus-visible-outline.js
create mode 100644 packages/eslint-config-supabase/rules/require-focus-visible-outline.test.js
diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json
index c95bbd984d6..4a08ab938f6 100644
--- a/apps/studio/.github/eslint-rule-baselines.json
+++ b/apps/studio/.github/eslint-rule-baselines.json
@@ -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
}
}
}
diff --git a/apps/studio/scripts/ratchet-rules.json b/apps/studio/scripts/ratchet-rules.json
index 4f34402cd78..42aba748097 100644
--- a/apps/studio/scripts/ratchet-rules.json
+++ b/apps/studio/scripts/ratchet-rules.json
@@ -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"
]
diff --git a/packages/eslint-config-supabase/next.js b/packages/eslint-config-supabase/next.js
index 20e41c405b7..ab6b9559b98 100644
--- a/packages/eslint-config-supabase/next.js
+++ b/packages/eslint-config-supabase/next.js
@@ -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',
},
}
diff --git a/packages/eslint-config-supabase/package.json b/packages/eslint-config-supabase/package.json
index 54b36915b7b..dba1387bd94 100644
--- a/packages/eslint-config-supabase/package.json
+++ b/packages/eslint-config-supabase/package.json
@@ -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:"
}
}
diff --git a/packages/eslint-config-supabase/rules/require-focus-visible-outline.js b/packages/eslint-config-supabase/rules/require-focus-visible-outline.js
new file mode 100644
index 00000000000..b70021ac383
--- /dev/null
+++ b/packages/eslint-config-supabase/rules/require-focus-visible-outline.js
@@ -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:
+ *
+ *
Item
+ *
+ * GOOD:
+ *
+ *
+ * Row
+ */
+
+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',
+ })
+ },
+ }
+ },
+}
diff --git a/packages/eslint-config-supabase/rules/require-focus-visible-outline.test.js b/packages/eslint-config-supabase/rules/require-focus-visible-outline.test.js
new file mode 100644
index 00000000000..4b04aaab611
--- /dev/null
+++ b/packages/eslint-config-supabase/rules/require-focus-visible-outline.test.js
@@ -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: '' },
+ { code: 'Row
' },
+ {
+ code: '',
+ },
+ { code: '' },
+ {
+ code: 'Group
',
+ },
+ {
+ code: 'Input group
',
+ },
+ {
+ code: 'Input group
',
+ },
+ { code: '' },
+ {
+ code: `
+ function Example() {
+ return
+ }
+ `,
+ },
+ {
+ code: '',
+ },
+ ],
+ invalid: [
+ {
+ code: '',
+ errors: [{ messageId: 'bareOutlineNone' }],
+ },
+ {
+ code: 'Item
',
+ errors: [{ messageId: 'bareOutlineNone' }],
+ },
+ {
+ code: '',
+ errors: [{ messageId: 'bareOutlineNone' }],
+ },
+ {
+ code: `
+ function Example() {
+ return
+ }
+ `,
+ errors: [{ messageId: 'bareOutlineNone' }],
+ },
+ {
+ code: 'Wrapper
',
+ errors: [{ messageId: 'bareOutlineNone' }],
+ },
+ ],
+})
+
+console.log('require-focus-visible-outline tests passed')
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 783197513ee..5ccb22fc75c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)