@a11yfred/neighbor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/lib/helpers-angular.js +131 -0
- package/lib/helpers-jsx.js +181 -0
- package/lib/helpers-vue.js +135 -0
- package/lib/helpers.js +37 -0
- package/lib/rules.js +2158 -0
- package/lib/ulam-rules.js +225 -0
- package/neighbor-eslint-angular.mjs +66 -0
- package/neighbor-eslint-vue.mjs +46 -0
- package/neighbor-eslint.mjs +56 -0
- package/neighbor-stylelint.mjs +196 -0
- package/package.json +61 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/ulam-rules.js
|
|
3
|
+
* Lint rules specific to @ulam framework patterns.
|
|
4
|
+
*
|
|
5
|
+
* These rules operate on JS/JSX call expressions and import declarations,
|
|
6
|
+
* not on JSX element visitors — they do not take the `h` adapter.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* no-announce-in-render — announce() called in component body, not effect/handler
|
|
10
|
+
* no-hash-router-in-remix — importing from siling-labuyo/hashRouter in a Remix project
|
|
11
|
+
* no-use-page-title-in-remix — usePageTitle() used alongside react-router imports
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── no-announce-in-render ───────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// announce() writes to a live region. Calling it directly in a component body
|
|
17
|
+
// fires on every render, spamming screen readers with repeated announcements.
|
|
18
|
+
// It must only be called inside useEffect, useLayoutEffect, or event handlers.
|
|
19
|
+
|
|
20
|
+
const ANNOUNCE_FNS = new Set(['announce', 'clearAnnouncements'])
|
|
21
|
+
const SAFE_PARENT_CALLS = new Set([
|
|
22
|
+
'useEffect', 'useLayoutEffect', 'useInsertionEffect',
|
|
23
|
+
'useCallback', 'useMemo',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
function isInsideSafeContext(node) {
|
|
27
|
+
let cur = node.parent
|
|
28
|
+
while (cur) {
|
|
29
|
+
// Arrow or function expression passed as argument to useEffect etc.
|
|
30
|
+
if (
|
|
31
|
+
cur.type === 'CallExpression' &&
|
|
32
|
+
cur.callee?.name &&
|
|
33
|
+
SAFE_PARENT_CALLS.has(cur.callee.name)
|
|
34
|
+
) return true
|
|
35
|
+
// Event handler: onClick={...}, onKeyDown={...}, etc.
|
|
36
|
+
if (
|
|
37
|
+
cur.type === 'JSXExpressionContainer' &&
|
|
38
|
+
cur.parent?.type === 'JSXAttribute' &&
|
|
39
|
+
cur.parent?.name?.name?.startsWith('on')
|
|
40
|
+
) return true
|
|
41
|
+
// Regular function (not a component body) — event listeners, async handlers
|
|
42
|
+
if (
|
|
43
|
+
cur.type === 'FunctionDeclaration' ||
|
|
44
|
+
cur.type === 'FunctionExpression' ||
|
|
45
|
+
cur.type === 'ArrowFunctionExpression'
|
|
46
|
+
) {
|
|
47
|
+
// Standalone function (not a callback) — safe
|
|
48
|
+
const parent = cur.parent
|
|
49
|
+
if (parent?.type !== 'CallExpression') return true
|
|
50
|
+
// It's a callback — only safe if the callee is a known safe hook
|
|
51
|
+
if (parent.callee?.name && SAFE_PARENT_CALLS.has(parent.callee.name)) return true
|
|
52
|
+
// Could be an event handler function passed as a prop — allow it
|
|
53
|
+
if (parent.callee?.type === 'MemberExpression') return true
|
|
54
|
+
// Nested callback (e.g. setState inside onClick) — keep traversing upward
|
|
55
|
+
// Don't bail here; let the loop continue to find the enclosing context
|
|
56
|
+
}
|
|
57
|
+
cur = cur.parent
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function makeNoAnnounceInRender() {
|
|
63
|
+
return {
|
|
64
|
+
meta: {
|
|
65
|
+
type: 'problem',
|
|
66
|
+
docs: { description: 'Disallow announce() called directly in a component render body' },
|
|
67
|
+
messages: {
|
|
68
|
+
inRender:
|
|
69
|
+
'`{{fn}}()` called outside a useEffect or event handler will fire on every render, ' +
|
|
70
|
+
'spamming screen readers. Move it into useEffect(() => { {{fn}}(...) }, [dep]) ' +
|
|
71
|
+
'or call it from an event handler. (@ulam/taho)',
|
|
72
|
+
},
|
|
73
|
+
schema: [],
|
|
74
|
+
},
|
|
75
|
+
create(context) {
|
|
76
|
+
return {
|
|
77
|
+
CallExpression(node) {
|
|
78
|
+
const name = node.callee?.name
|
|
79
|
+
if (!name || !ANNOUNCE_FNS.has(name)) return
|
|
80
|
+
if (isInsideSafeContext(node)) return
|
|
81
|
+
context.report({ node, messageId: 'inRender', data: { fn: name } })
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── no-hash-router-in-remix ─────────────────────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// The @ulam hash router (siling-labuyo/hashRouter, @ulam/sili/hashRouter) is a
|
|
91
|
+
// fallback for plain SPA builds. In Remix, file-based routing replaces it.
|
|
92
|
+
// Importing from the hash router in a file that also uses react-router means
|
|
93
|
+
// the migration to siling-mahaba is incomplete.
|
|
94
|
+
|
|
95
|
+
const HASH_ROUTER_PATHS = new Set([
|
|
96
|
+
'siling-labuyo/hashRouter',
|
|
97
|
+
'@ulam/sili/hashRouter',
|
|
98
|
+
'@ulam/siling-labuyo/hashRouter',
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
const REMIX_PATHS = new Set([
|
|
102
|
+
'react-router',
|
|
103
|
+
'@remix-run/react',
|
|
104
|
+
'react-router-dom',
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
export function makeNoHashRouterInRemix() {
|
|
108
|
+
return {
|
|
109
|
+
meta: {
|
|
110
|
+
type: 'suggestion',
|
|
111
|
+
docs: { description: 'Disallow @ulam hash router imports alongside react-router' },
|
|
112
|
+
messages: {
|
|
113
|
+
hashRouter:
|
|
114
|
+
'Importing from the @ulam hash router alongside react-router means the Remix migration ' +
|
|
115
|
+
'is incomplete. Replace hash router usage with siling-mahaba equivalents: ' +
|
|
116
|
+
'useRouter/useRouteMatch from @ulam/siling-mahaba. (@ulam/siling-mahaba)',
|
|
117
|
+
},
|
|
118
|
+
schema: [],
|
|
119
|
+
},
|
|
120
|
+
create(context) {
|
|
121
|
+
let hasRemixImport = false
|
|
122
|
+
const hashRouterNodes = []
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ImportDeclaration(node) {
|
|
126
|
+
const src = node.source.value
|
|
127
|
+
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
128
|
+
if (HASH_ROUTER_PATHS.has(src) || src.includes('/hashRouter')) {
|
|
129
|
+
hashRouterNodes.push(node)
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
'Program:exit'() {
|
|
133
|
+
if (!hasRemixImport) return
|
|
134
|
+
for (const node of hashRouterNodes) {
|
|
135
|
+
context.report({ node, messageId: 'hashRouter' })
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── no-use-page-title-in-remix ──────────────────────────────────────────────
|
|
144
|
+
//
|
|
145
|
+
// usePageTitle() from siling-labuyo sets document.title imperatively.
|
|
146
|
+
// In Remix, page titles are set via the `meta` export on each route module.
|
|
147
|
+
// Using usePageTitle() alongside react-router imports means the migration shim
|
|
148
|
+
// has not been cleaned up.
|
|
149
|
+
|
|
150
|
+
const USE_PAGE_TITLE_SOURCES = new Set([
|
|
151
|
+
'siling-labuyo/hooks/usePageTitle',
|
|
152
|
+
'@ulam/sili',
|
|
153
|
+
'@ulam/siling-labuyo',
|
|
154
|
+
'@ulam/siling-mahaba',
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
export function makeNoUsePageTitleInRemix() {
|
|
158
|
+
return {
|
|
159
|
+
meta: {
|
|
160
|
+
type: 'suggestion',
|
|
161
|
+
docs: { description: 'Disallow usePageTitle() in Remix — use the meta export instead' },
|
|
162
|
+
messages: {
|
|
163
|
+
usePageTitle:
|
|
164
|
+
'`usePageTitle()` sets document.title imperatively, which conflicts with Remix\'s ' +
|
|
165
|
+
'declarative `meta` export. Export a `meta` function from each route module instead: ' +
|
|
166
|
+
'`export const meta = () => [{ title: "App | Page" }]`. ' +
|
|
167
|
+
'Then remove this import. (@ulam/siling-mahaba)',
|
|
168
|
+
},
|
|
169
|
+
schema: [],
|
|
170
|
+
},
|
|
171
|
+
create(context) {
|
|
172
|
+
let hasRemixImport = false
|
|
173
|
+
const usePageTitleNodes = []
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ImportDeclaration(node) {
|
|
177
|
+
const src = node.source.value
|
|
178
|
+
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
179
|
+
const importsUsePageTitle = node.specifiers.some(
|
|
180
|
+
s => s.type === 'ImportSpecifier' && s.imported?.name === 'usePageTitle'
|
|
181
|
+
)
|
|
182
|
+
if (importsUsePageTitle && USE_PAGE_TITLE_SOURCES.has(src)) {
|
|
183
|
+
usePageTitleNodes.push(node)
|
|
184
|
+
}
|
|
185
|
+
// Also catch wildcard re-exports like @ulam/siling-mahaba (which re-exports it)
|
|
186
|
+
if (importsUsePageTitle) usePageTitleNodes.push(node)
|
|
187
|
+
},
|
|
188
|
+
'Program:exit'() {
|
|
189
|
+
if (!hasRemixImport) return
|
|
190
|
+
// Deduplicate (wildcard catch above may double-push)
|
|
191
|
+
const seen = new Set()
|
|
192
|
+
for (const node of usePageTitleNodes) {
|
|
193
|
+
if (seen.has(node)) continue
|
|
194
|
+
seen.add(node)
|
|
195
|
+
context.report({ node, messageId: 'usePageTitle' })
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── All ulam rule factories ──────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export const ULAM_RULE_FACTORIES = {
|
|
206
|
+
'no-announce-in-render': makeNoAnnounceInRender,
|
|
207
|
+
'no-hash-router-in-remix': makeNoHashRouterInRemix,
|
|
208
|
+
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildUlamRules() {
|
|
212
|
+
const rules = {}
|
|
213
|
+
for (const [name, factory] of Object.entries(ULAM_RULE_FACTORIES)) {
|
|
214
|
+
rules[name] = factory()
|
|
215
|
+
}
|
|
216
|
+
return rules
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function buildUlamRecommendedRules(ns) {
|
|
220
|
+
return {
|
|
221
|
+
[`${ns}/no-announce-in-render`]: 'error',
|
|
222
|
+
[`${ns}/no-hash-router-in-remix`]: 'warn',
|
|
223
|
+
[`${ns}/no-use-page-title-in-remix`]: 'warn',
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor — ESLint plugin (Angular templates)
|
|
3
|
+
*
|
|
4
|
+
* Flags the same ARIA anti-patterns as neighbor-eslint.mjs but for Angular
|
|
5
|
+
* component templates. Requires @angular-eslint/template-parser.
|
|
6
|
+
*
|
|
7
|
+
* Rules that require ancestor walking (no-log-with-interactive-children,
|
|
8
|
+
* no-menu-role-on-nav, no-heading-inside-interactive) are limited because
|
|
9
|
+
* @angular-eslint/template-parser does not attach parent references to nodes.
|
|
10
|
+
* Those rules will still fire for direct matches but cannot walk the tree.
|
|
11
|
+
*
|
|
12
|
+
* Usage in eslint.config.js:
|
|
13
|
+
* import angularTemplateParser from '@angular-eslint/template-parser'
|
|
14
|
+
* import neighbor from '@a11yfred/neighbor/angular'
|
|
15
|
+
*
|
|
16
|
+
* export default [
|
|
17
|
+
* {
|
|
18
|
+
* files: ['**\/*.html'],
|
|
19
|
+
* languageOptions: { parser: angularTemplateParser },
|
|
20
|
+
* plugins: { '@a11yfred/neighbor': neighbor },
|
|
21
|
+
* rules: neighbor.configs.recommended.rules,
|
|
22
|
+
* },
|
|
23
|
+
* ]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { h } from './lib/helpers-angular.js'
|
|
27
|
+
import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
|
|
28
|
+
|
|
29
|
+
const NS = '@a11yfred/neighbor'
|
|
30
|
+
const rules = buildRules(h)
|
|
31
|
+
const plugin = { meta: { name: `${NS}/angular` }, rules }
|
|
32
|
+
|
|
33
|
+
let angularA11y = null
|
|
34
|
+
try { angularA11y = (await import('@angular-eslint/eslint-plugin-template')).default } catch {}
|
|
35
|
+
|
|
36
|
+
const ANGULAR_A11Y_RULES = [
|
|
37
|
+
'alt-text', 'click-events-have-key-events', 'elements-content',
|
|
38
|
+
'interactive-supports-focus', 'label-has-associated-control',
|
|
39
|
+
'mouse-events-have-key-events', 'no-autofocus', 'no-distracting-elements',
|
|
40
|
+
'no-positive-tabindex', 'role-has-required-aria', 'table-scope', 'valid-aria',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function getAngularA11yRules(plugin) {
|
|
44
|
+
const out = {}
|
|
45
|
+
for (const rule of ANGULAR_A11Y_RULES) {
|
|
46
|
+
if (plugin.rules?.[rule]) out[`@angular-eslint/template/${rule}`] = 'error'
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
...plugin,
|
|
53
|
+
configs: {
|
|
54
|
+
recommended: {
|
|
55
|
+
plugins: {
|
|
56
|
+
[NS]: plugin,
|
|
57
|
+
...(angularA11y ? { '@angular-eslint/template': angularA11y } : {}),
|
|
58
|
+
},
|
|
59
|
+
rules: {
|
|
60
|
+
...(angularA11y ? getAngularA11yRules(angularA11y) : {}),
|
|
61
|
+
...buildRecommendedRules(NS),
|
|
62
|
+
...buildPortabilityRules(NS),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor — ESLint plugin (Vue SFCs)
|
|
3
|
+
*
|
|
4
|
+
* Flags the same ARIA anti-patterns as neighbor-eslint.mjs but for Vue templates.
|
|
5
|
+
* Requires vue-eslint-parser as the project's ESLint parser for .vue files.
|
|
6
|
+
*
|
|
7
|
+
* Usage in eslint.config.js:
|
|
8
|
+
* import vueParser from 'vue-eslint-parser'
|
|
9
|
+
* import neighbor from '@a11yfred/neighbor/vue'
|
|
10
|
+
*
|
|
11
|
+
* export default [
|
|
12
|
+
* {
|
|
13
|
+
* files: ['**\/*.vue'],
|
|
14
|
+
* languageOptions: { parser: vueParser },
|
|
15
|
+
* plugins: { '@a11yfred/neighbor': neighbor },
|
|
16
|
+
* rules: neighbor.configs.recommended.rules,
|
|
17
|
+
* },
|
|
18
|
+
* ]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { h } from './lib/helpers-vue.js'
|
|
22
|
+
import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
|
|
23
|
+
|
|
24
|
+
const NS = '@a11yfred/neighbor'
|
|
25
|
+
const rules = buildRules(h)
|
|
26
|
+
const plugin = { meta: { name: `${NS}/vue` }, rules }
|
|
27
|
+
|
|
28
|
+
let vueA11y = null
|
|
29
|
+
try { vueA11y = (await import('eslint-plugin-vuejs-accessibility')).default } catch {}
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
...plugin,
|
|
33
|
+
configs: {
|
|
34
|
+
recommended: {
|
|
35
|
+
plugins: {
|
|
36
|
+
[NS]: plugin,
|
|
37
|
+
...(vueA11y ? { 'vuejs-accessibility': vueA11y } : {}),
|
|
38
|
+
},
|
|
39
|
+
rules: {
|
|
40
|
+
...(vueA11y ? vueA11y.configs['flat/recommended'].rules : {}),
|
|
41
|
+
...buildRecommendedRules(NS),
|
|
42
|
+
...buildPortabilityRules(NS),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor — ESLint plugin (React / JSX)
|
|
3
|
+
*
|
|
4
|
+
* Flags ARIA patterns that are widely derided, semantically wrong, or have
|
|
5
|
+
* poor/no AT support — but are not caught by eslint-plugin-jsx-a11y recommended.
|
|
6
|
+
*
|
|
7
|
+
* Sources and credits:
|
|
8
|
+
* Adrian Roselli adrianroselli.com
|
|
9
|
+
* Heydon Pickering heydonworks.com, inclusive-components.design
|
|
10
|
+
* Scott O'Hara scottohara.me
|
|
11
|
+
* Patrick Lauke splintered.co.uk, patrickhlauke.github.io/aria
|
|
12
|
+
* Karl Groves karlgroves.com
|
|
13
|
+
* Marcy Sutton marcysutton.com
|
|
14
|
+
* Eric Eggert yatil.net
|
|
15
|
+
* WAI-ARIA APG w3.org/WAI/ARIA/apg
|
|
16
|
+
* ARIA 1.2 spec w3.org/TR/wai-aria-1.2
|
|
17
|
+
*
|
|
18
|
+
* Rules already covered by jsx-a11y recommended (not duplicated here):
|
|
19
|
+
* aria-hidden on focusable → jsx-a11y/no-aria-hidden-on-focusable
|
|
20
|
+
* presentation/none on interactive → jsx-a11y/no-interactive-element-to-noninteractive-role
|
|
21
|
+
* redundant role → jsx-a11y/no-redundant-roles
|
|
22
|
+
* prefer semantic element → jsx-a11y/prefer-tag-over-role
|
|
23
|
+
* invalid role value → jsx-a11y/aria-role
|
|
24
|
+
* invalid aria prop → jsx-a11y/aria-props
|
|
25
|
+
* tabindex > 0 → jsx-a11y/tabindex-no-positive
|
|
26
|
+
* tabindex on non-interactive → jsx-a11y/no-noninteractive-tabindex
|
|
27
|
+
* img missing alt → jsx-a11y/alt-text
|
|
28
|
+
* input missing label → jsx-a11y/label-has-associated-control
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
|
32
|
+
import { h } from './lib/helpers-jsx.js'
|
|
33
|
+
import { buildRules, buildRecommendedRules } from './lib/rules.js'
|
|
34
|
+
import { buildUlamRules, buildUlamRecommendedRules } from './lib/ulam-rules.js'
|
|
35
|
+
|
|
36
|
+
const NS = '@a11yfred/neighbor'
|
|
37
|
+
const rules = { ...buildRules(h), ...buildUlamRules() }
|
|
38
|
+
|
|
39
|
+
const plugin = { meta: { name: NS }, rules }
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
...plugin,
|
|
43
|
+
configs: {
|
|
44
|
+
recommended: {
|
|
45
|
+
plugins: {
|
|
46
|
+
[NS]: plugin,
|
|
47
|
+
'jsx-a11y': jsxA11y,
|
|
48
|
+
},
|
|
49
|
+
rules: {
|
|
50
|
+
...jsxA11y.configs.recommended.rules,
|
|
51
|
+
...buildRecommendedRules(NS),
|
|
52
|
+
...buildUlamRecommendedRules(NS),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor — Stylelint plugin
|
|
3
|
+
*
|
|
4
|
+
* Rules:
|
|
5
|
+
* ulam/user-preferences — Warn when motion, transparency, or alpha colors
|
|
6
|
+
* are used without @media (prefers-*) fallbacks.
|
|
7
|
+
* ulam/no-outline-none — Disallow bare outline:none/0 outside :focus selectors.
|
|
8
|
+
*
|
|
9
|
+
* Sources and credits:
|
|
10
|
+
* WCAG 2.1 / 2.2 w3.org/TR/WCAG21, w3.org/TR/WCAG22
|
|
11
|
+
* WebAIM webaim.org
|
|
12
|
+
* double-great/stylelint-a11y github.com/double-great/stylelint-a11y
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const defined = (x) => x !== undefined && x !== null;
|
|
16
|
+
|
|
17
|
+
/** True if the node or any ancestor is a prefers-* / forced-colors media block */
|
|
18
|
+
function insidePreferencesMedia(node) {
|
|
19
|
+
let current = node.parent;
|
|
20
|
+
while (defined(current)) {
|
|
21
|
+
if (
|
|
22
|
+
current.type === 'atrule' &&
|
|
23
|
+
current.name === 'media' &&
|
|
24
|
+
/prefers-|forced-colors/.test(current.params)
|
|
25
|
+
) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
current = current.parent;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True if the value string contains an alpha channel (rgb/hsl with slash, or 8-digit hex) */
|
|
34
|
+
function hasAlphaChannel(value) {
|
|
35
|
+
// rgb(r g b / a) or rgba() or hsl(h s l / a)
|
|
36
|
+
if (/\b(rgb|hsl)a?\s*\(/.test(value) && /\/\s*[01]?\.?\d+[^)]*\)/.test(value)) return true;
|
|
37
|
+
// 8-digit hex #rrggbbaa
|
|
38
|
+
if (/#[0-9a-fA-F]{8}\b/.test(value)) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True if the opacity value is a structural endpoint (0 or 1), not a dim */
|
|
43
|
+
function isStructuralOpacity(value) {
|
|
44
|
+
const n = parseFloat(value.trim());
|
|
45
|
+
return n === 0 || n === 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ruleName = 'ulam/user-preferences';
|
|
49
|
+
|
|
50
|
+
const messages = {
|
|
51
|
+
opacity: (value) =>
|
|
52
|
+
`opacity: ${value} creates a transparency effect. Add a fallback in @media (prefers-reduced-transparency: reduce) that uses an explicit color token instead. See src/components/ui/user-preferences.css.`,
|
|
53
|
+
animation: (prop, value) =>
|
|
54
|
+
`${prop}: ${value} uses motion. Add a fallback in @media (prefers-reduced-motion: reduce) that disables or stills this animation. See src/components/ui/user-preferences.css.`,
|
|
55
|
+
alpha: (value) =>
|
|
56
|
+
`Color value "${value}" uses an alpha channel. Add an opaque fallback in @media (prefers-reduced-transparency: reduce). See src/components/ui/user-preferences.css.`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const meta = { url: 'https://github.com/a11yfred/neighbor' };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Collect all selectors that appear inside a prefers-reduced-* or forced-colors
|
|
63
|
+
* media block anywhere in the file. Used to suppress warnings when an override exists.
|
|
64
|
+
* Splits comma-separated selector lists so each individual selector is tracked.
|
|
65
|
+
*/
|
|
66
|
+
function collectCoveredSelectors(root) {
|
|
67
|
+
const covered = new Set();
|
|
68
|
+
root.walkAtRules('media', (atRule) => {
|
|
69
|
+
if (!/prefers-|forced-colors/.test(atRule.params)) return;
|
|
70
|
+
atRule.walkRules((ruleNode) => {
|
|
71
|
+
// Split comma-separated selector lists
|
|
72
|
+
for (const part of ruleNode.selector.split(',')) {
|
|
73
|
+
const sel = part.trim();
|
|
74
|
+
covered.add(sel);
|
|
75
|
+
// Also add bare selector without pseudo-classes/pseudo-elements
|
|
76
|
+
covered.add(sel.replace(/::[^,\s{]+|:[^,\s{(]+(\([^)]*\))?/g, '').trim());
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return covered;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True if the given selector (or its bare form) is in the covered set */
|
|
84
|
+
function isCovered(selector, covered) {
|
|
85
|
+
// Split comma lists in the base selector too
|
|
86
|
+
for (const part of selector.split(',')) {
|
|
87
|
+
const norm = part.trim();
|
|
88
|
+
if (covered.has(norm)) return true;
|
|
89
|
+
const bare = norm.replace(/::[^,\s{]+|:[^,\s{(]+(\([^)]*\))?/g, '').trim();
|
|
90
|
+
if (covered.has(bare)) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @type {import('stylelint').Rule} */
|
|
96
|
+
function rule(primaryOption) {
|
|
97
|
+
return (root, result) => {
|
|
98
|
+
// Only enforce inside src/components/ui/
|
|
99
|
+
const filePath = (root.source?.input?.file ?? '').replace(/\\/g, '/');
|
|
100
|
+
if (!filePath.includes('src/components/ui')) return;
|
|
101
|
+
// Never enforce inside user-preferences.css itself
|
|
102
|
+
if (filePath.includes('user-preferences.css')) return;
|
|
103
|
+
|
|
104
|
+
// Pre-scan: collect selectors already covered by prefers overrides in this file
|
|
105
|
+
const covered = collectCoveredSelectors(root);
|
|
106
|
+
|
|
107
|
+
root.walkDecls((decl) => {
|
|
108
|
+
if (insidePreferencesMedia(decl)) return;
|
|
109
|
+
|
|
110
|
+
// If the containing rule's selector is already overridden in a prefers block, skip
|
|
111
|
+
const parentSelector = decl.parent?.selector ?? '';
|
|
112
|
+
if (parentSelector && isCovered(parentSelector, covered)) return;
|
|
113
|
+
|
|
114
|
+
const prop = decl.prop.toLowerCase();
|
|
115
|
+
const value = decl.value;
|
|
116
|
+
|
|
117
|
+
// opacity — warn on non-structural values (i.e. dims like 0.5, 0.75)
|
|
118
|
+
if (prop === 'opacity' && !isStructuralOpacity(value)) {
|
|
119
|
+
decl.warn(result, messages.opacity(value), { rule: ruleName });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// animation or transition
|
|
124
|
+
if (prop === 'animation' || prop === 'transition' || prop === 'animation-name') {
|
|
125
|
+
// Skip "none" values — they're already the reduced state
|
|
126
|
+
if (/^none\b/i.test(value.trim())) return;
|
|
127
|
+
decl.warn(result, messages.animation(prop, value), { rule: ruleName });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Alpha-channel color values on visual properties
|
|
132
|
+
const visualProps = new Set([
|
|
133
|
+
'background', 'background-color', 'color', 'border', 'border-color',
|
|
134
|
+
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
135
|
+
'outline-color', 'box-shadow', 'text-shadow', 'fill', 'stroke',
|
|
136
|
+
]);
|
|
137
|
+
if (visualProps.has(prop) && hasAlphaChannel(value)) {
|
|
138
|
+
decl.warn(result, messages.alpha(value), { rule: ruleName });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const userPreferences = { ruleName, rule, meta };
|
|
145
|
+
|
|
146
|
+
// ─── Rule: ulam/no-outline-none ──────────────────────────────────────────────
|
|
147
|
+
// outline: none / outline: 0 removes the browser's default keyboard focus
|
|
148
|
+
// indicator. This is one of the most common keyboard accessibility failures —
|
|
149
|
+
// keyboard users lose all visual indication of where focus is.
|
|
150
|
+
//
|
|
151
|
+
// Only fires when the declaration is NOT inside a :focus-visible, :focus, or
|
|
152
|
+
// :focus-within selector, and no sibling :focus-visible rule overrides it in
|
|
153
|
+
// the same block.
|
|
154
|
+
//
|
|
155
|
+
// Ref: WCAG 2.4.7 (Focus Visible); WebAIM; Roselli; cross-practitioner consensus
|
|
156
|
+
|
|
157
|
+
const noOutlineNoneRuleName = 'ulam/no-outline-none';
|
|
158
|
+
|
|
159
|
+
const noOutlineNoneMessages = {
|
|
160
|
+
removed: (value) =>
|
|
161
|
+
`outline: ${value} removes the keyboard focus indicator. Add a :focus-visible rule with a visible outline or custom focus style. (WCAG 2.4.7 / WebAIM)`,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const noOutlineNoneMeta = { url: 'https://github.com/a11yfred/neighbor' };
|
|
165
|
+
|
|
166
|
+
/** Returns true if the selector string targets a focus state. */
|
|
167
|
+
function isFocusSelector(selector) {
|
|
168
|
+
return /:focus(?:-visible|-within)?/i.test(selector);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @type {import('stylelint').Rule} */
|
|
172
|
+
function noOutlineNoneRule(_primaryOption) {
|
|
173
|
+
return (root, result) => {
|
|
174
|
+
root.walkDecls(/^outline$/i, (decl) => {
|
|
175
|
+
const value = decl.value.trim().toLowerCase();
|
|
176
|
+
if (value !== 'none' && value !== '0') return;
|
|
177
|
+
|
|
178
|
+
// If this declaration is already inside a :focus / :focus-visible rule, it's fine —
|
|
179
|
+
// the author is intentionally restyling focus, which is acceptable as long as they
|
|
180
|
+
// provide an alternative (we can't verify the alternative statically, so we allow it).
|
|
181
|
+
const parent = decl.parent;
|
|
182
|
+
if (parent?.type === 'rule' && isFocusSelector(parent.selector ?? '')) return;
|
|
183
|
+
|
|
184
|
+
// Flag it
|
|
185
|
+
decl.warn(result, noOutlineNoneMessages.removed(decl.value), { rule: noOutlineNoneRuleName });
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const noOutlineNone = {
|
|
191
|
+
ruleName: noOutlineNoneRuleName,
|
|
192
|
+
rule: noOutlineNoneRule,
|
|
193
|
+
meta: noOutlineNoneMeta,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export default [userPreferences, noOutlineNone];
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@a11yfred/neighbor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Accessibility linting plugin for a11yfred — Stylelint (user-preference fallbacks) and ESLint (bad ARIA patterns). Won't you be my neighbor?",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": ["lib", "*.mjs", "LICENSE", "README.md"],
|
|
8
|
+
"main": "./neighbor-stylelint.mjs",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/a11yfred/neighbor.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/a11yfred/neighbor#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/a11yfred/neighbor/issues"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./neighbor-stylelint.mjs",
|
|
19
|
+
"./stylelint": "./neighbor-stylelint.mjs",
|
|
20
|
+
"./eslint": "./neighbor-eslint.mjs",
|
|
21
|
+
"./eslint-vue": "./neighbor-eslint-vue.mjs",
|
|
22
|
+
"./eslint-angular": "./neighbor-eslint-angular.mjs"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"stylelint",
|
|
26
|
+
"stylelint-plugin",
|
|
27
|
+
"eslint",
|
|
28
|
+
"eslint-plugin",
|
|
29
|
+
"a11yfred",
|
|
30
|
+
"css",
|
|
31
|
+
"aria",
|
|
32
|
+
"accessibility",
|
|
33
|
+
"a11y"
|
|
34
|
+
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@angular-eslint/eslint-plugin-template": ">=17",
|
|
37
|
+
"eslint": ">=8",
|
|
38
|
+
"eslint-plugin-jsx-a11y": ">=6",
|
|
39
|
+
"eslint-plugin-vuejs-accessibility": ">=2",
|
|
40
|
+
"stylelint": ">=14"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"eslint": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"eslint-plugin-vuejs-accessibility": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@angular-eslint/eslint-plugin-template": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"stylelint": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"eslint": "^9.39.4",
|
|
58
|
+
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
59
|
+
"stylelint": "^17.11.0"
|
|
60
|
+
}
|
|
61
|
+
}
|