mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
chore(eslint): warn on bare outline-none without focus replacement
This commit is contained in:
1 parent
fac534d2ca
commit
8f7a080f09
7 files changed
+329
-3
No files matched your search
+14
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user