@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.
@@ -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
+ }