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)