eslint-plugin-aria-state-validator 1.1.0 → 1.1.1

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/README.ko.md CHANGED
@@ -41,18 +41,16 @@ npm install --save-dev eslint-plugin-aria-state-validator
41
41
  import ariaStateValidator from "eslint-plugin-aria-state-validator";
42
42
 
43
43
  export default [
44
- {
45
- plugins: {
46
- "aria-state-validator": ariaStateValidator,
47
- },
48
- rules: {
49
- "aria-state-validator/state-dependent-aria-validator": "warn",
50
- "aria-state-validator/static-aria-validator": "warn",
51
- },
52
- },
44
+ ...ariaStateValidator.configs["flat/recommended"],
53
45
  ];
54
46
  ```
55
47
 
48
+ 사용 가능한 Flat preset:
49
+ - `flat/recommended`
50
+ - `flat/strict`
51
+ - `flat/dynamic-only`
52
+ - `flat/static-only`
53
+
56
54
  ### Legacy ESLintRC Config
57
55
 
58
56
  ```json
@@ -68,17 +66,17 @@ export default [
68
66
  ### 설정 프리셋
69
67
 
70
68
  ```javascript
71
- // 권장 설정 (경고)
72
- ...ariaStateValidator.configs.recommended
73
-
74
- // 엄격한 설정 (에러)
75
- ...ariaStateValidator.configs.strict
76
-
77
- // 동적 검증만
78
- ...ariaStateValidator.configs['dynamic-only']
79
-
80
- // 정적 검증만
81
- ...ariaStateValidator.configs['static-only']
69
+ // Flat Config용 preset
70
+ ...ariaStateValidator.configs["flat/recommended"]
71
+ ...ariaStateValidator.configs["flat/strict"]
72
+ ...ariaStateValidator.configs["flat/dynamic-only"]
73
+ ...ariaStateValidator.configs["flat/static-only"]
74
+
75
+ // Legacy eslintrc용 preset
76
+ extends: ["plugin:aria-state-validator/recommended"]
77
+ extends: ["plugin:aria-state-validator/strict"]
78
+ extends: ["plugin:aria-state-validator/dynamic-only"]
79
+ extends: ["plugin:aria-state-validator/static-only"]
82
80
  ```
83
81
 
84
82
  ## 두 가지 규칙으로 포괄적인 ARIA 검증
@@ -89,6 +87,28 @@ export default [
89
87
 
90
88
  ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동적으로 업데이트되는지 검증합니다.
91
89
 
90
+ 규칙 옵션:
91
+ - `interactiveHandlers` (`string[]`): 인터랙티브 핸들러로 간주할 이벤트 prop 목록 재정의 (기본값: `onClick`, `onKeyDown`, `onKeyPress`, `onChange`, `onToggle`)
92
+ - `strictBooleanInference` (`boolean`): `true`(기본값)면 불리언으로 추론되는 식별자만 경고, `false`면 식별자 바인딩 전체를 불리언 위험으로 간주
93
+ - `ignorePatterns` (`string[]`): 속성 이름 또는 값 소스 텍스트와 매칭되는 항목을 검사에서 제외할 정규식 패턴
94
+
95
+ 예시:
96
+
97
+ ```javascript
98
+ {
99
+ rules: {
100
+ "aria-state-validator/state-dependent-aria-validator": [
101
+ "warn",
102
+ {
103
+ interactiveHandlers: ["onClick", "onPointerDown"],
104
+ strictBooleanInference: true,
105
+ ignorePatterns: ["^aria-current$", "^\\{isOpen\\}$"],
106
+ },
107
+ ],
108
+ },
109
+ }
110
+ ```
111
+
92
112
  ### 규칙 2: `static-aria-validator` (정적 ARIA 검증)
93
113
 
94
114
  사용된 ARIA 속성의 **정확성**을 검증합니다:
@@ -118,7 +138,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
118
138
  <button aria-expanded={isOpen ? 'true' : 'false'}>토글</button>
119
139
  ```
120
140
 
121
- **자동 수정 가능:** `eslint --fix`가 자동으로 올바른 문자열 형식으로 변환합니다
141
+ **제안 제공:** 규칙이 문자열 형식(`'true'/'false'`)으로 바꾸는 안전한 제안을 제공합니다.
122
142
 
123
143
  ### 2. 정적 값 + 인터랙티브 핸들러 감지
124
144
 
@@ -136,21 +156,21 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
136
156
  </button>
137
157
  ```
138
158
 
139
- **스마트 자동 수정:** 핸들러에 상태 변수 참조가 있으면 (예: `() => setExpanded(!expanded)`), 플러그인이 자동으로 변수 이름을 추출하여 ARIA 속성에 바인딩합니다:
159
+ **스마트 제안:** 핸들러에 상태 변수 참조가 있으면 (예: `() => setExpanded(!expanded)`), 플러그인이 변수 이름을 추출해 ARIA 속성 바인딩 제안을 제공합니다:
140
160
 
141
161
  ```jsx
142
- // ❌ 자동 수정
162
+ // ❌ 제안
143
163
  <div onClick={() => setExpanded(!expanded)} aria-expanded="false">
144
164
  클릭하여 확장
145
165
  </div>
146
166
 
147
- // ✅ 자동 수정 후 (자동으로 'expanded' 변수 감지)
167
+ // ✅ 제안 적용 후 (자동으로 'expanded' 변수 감지)
148
168
  <div onClick={() => setExpanded(!expanded)} aria-expanded={expanded ? 'true' : 'false'}>
149
169
  클릭하여 확장
150
170
  </div>
151
171
  ```
152
172
 
153
- **자동 수정 지원 패턴:**
173
+ **제안 지원 패턴:**
154
174
  - `() => setX(!x)`
155
175
  - `() => setX(true)`
156
176
  - `() => setX(prev => !prev)`
@@ -163,8 +183,8 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
163
183
  // ❌ 오류: role="button"인데 aria-pressed가 정적
164
184
  <div role="button" aria-pressed="false">버튼</div>
165
185
 
166
- // ❌ 오류: role="switch"에 필수 속성 aria-checked 누락
167
- <div role="switch">토글</div>
186
+ // ❌ 오류: 인터랙티브 switch role에서 aria-checked 누락
187
+ <div role="switch" onClick={toggle}>토글</div>
168
188
 
169
189
  // ✅ 올바름: 동적 상태 바인딩
170
190
  <div role="button" aria-pressed={isPressed ? 'true' : 'false'}>버튼</div>
@@ -329,13 +349,14 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
329
349
 
330
350
  - **기반:** ESLint 플러그인 (Node.js 환경)
331
351
  - **파서:** JSX/TSX AST 파싱을 위한 `@babel/eslint-parser` 또는 TypeScript 파서
352
+ - **ARIA 데이터 소스:** 역할-속성 호환성 검증을 위한 `aria-query` 정의 데이터
332
353
  - **분석:** JSX 요소에 대한 Visitor 패턴
333
354
 
334
355
  ### 분석 로직
335
356
 
336
357
  1. **JSXElement 순회:** `role` 및 `aria-*` 속성 식별
337
- 2. **스코프 추적:** `context.getScope()`를 사용하여 ARIA 속성에 바인딩된 변수 추적
338
- 3. **상태 추론:** 값이 `useState` 훅이나 props에서 파생되었는지 판단
358
+ 2. **패턴 분석:** JSX 표현식에서 불리언 유사 바인딩과 정적 값 패턴 감지
359
+ 3. **상태 추론 휴리스틱:** 핸들러/세터 패턴(예: `setX(!x)`)에서 상태 변수 추론
339
360
  4. **패턴 검증:** 올바른 문자열 변환 패턴 확인
340
361
 
341
362
  ## 기대 효과
@@ -346,7 +367,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
346
367
 
347
368
  ### 2. 개발자 생산성
348
369
 
349
- `eslint --fix`로 반복적인 ARIA 상태 바인딩 오류를 자동 수정
370
+ 반복적인 ARIA 상태 바인딩 오류에 대해 타겟 제안 제공
350
371
 
351
372
  ### 3. 표준화
352
373
 
@@ -360,19 +381,13 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
360
381
  npm test
361
382
  ```
362
383
 
363
- ### 빌드
364
-
365
- ```bash
366
- npm run build # 해당되는 경우
367
- ```
368
-
369
384
  ## 기여
370
385
 
371
386
  기여는 언제나 환영합니다! 이슈나 풀 리퀘스트를 자유롭게 제출해주세요.
372
387
 
373
388
  ## 라이선스
374
389
 
375
- ISC
390
+ MIT
376
391
 
377
392
  ## 키워드
378
393
 
package/README.md CHANGED
@@ -40,17 +40,16 @@ npm install --save-dev eslint-plugin-aria-state-validator
40
40
  import ariaStateValidator from 'eslint-plugin-aria-state-validator';
41
41
 
42
42
  export default [
43
- {
44
- plugins: {
45
- 'aria-state-validator': ariaStateValidator
46
- },
47
- rules: {
48
- 'aria-state-validator/state-dependent-aria-validator': 'warn'
49
- }
50
- }
43
+ ...ariaStateValidator.configs['flat/recommended']
51
44
  ];
52
45
  ```
53
46
 
47
+ Available flat presets:
48
+ - `flat/recommended`
49
+ - `flat/strict`
50
+ - `flat/dynamic-only`
51
+ - `flat/static-only`
52
+
54
53
  ### Legacy ESLintRC Config
55
54
 
56
55
  ```json
@@ -70,6 +69,25 @@ This plugin provides two complementary rules:
70
69
 
71
70
  Validates that ARIA **state** attributes are properly bound to component state and update dynamically.
72
71
 
72
+ Rule options:
73
+ - `interactiveHandlers` (`string[]`): Override which event props are treated as interactive (default: `onClick`, `onKeyDown`, `onKeyPress`, `onChange`, `onToggle`)
74
+ - `strictBooleanInference` (`boolean`): When `true` (default), only flags identifiers inferred as boolean-like; when `false`, treats any identifier binding as boolean-risk
75
+ - `ignorePatterns` (`string[]`): Regex patterns to skip checks for matching attribute names or value source text
76
+
77
+ Example:
78
+
79
+ ```javascript
80
+ {
81
+ rules: {
82
+ 'aria-state-validator/state-dependent-aria-validator': ['warn', {
83
+ interactiveHandlers: ['onClick', 'onPointerDown'],
84
+ strictBooleanInference: true,
85
+ ignorePatterns: ['^aria-current$', '^\\{isOpen\\}$']
86
+ }]
87
+ }
88
+ }
89
+ ```
90
+
73
91
  ### Rule 2: `static-aria-validator` (Static ARIA Validation)
74
92
 
75
93
  Validates the **correctness** of ARIA attributes when they are used:
@@ -96,7 +114,7 @@ Validates the **correctness** of ARIA attributes when they are used:
96
114
  <button aria-expanded={isOpen ? 'true' : 'false'}>Toggle</button>
97
115
  ```
98
116
 
99
- **Auto-fix Available:** `eslint --fix` will automatically convert to proper string format
117
+ **Suggestion Available:** The rule provides a safe suggestion to convert bindings to string format (`'true'/'false'`).
100
118
 
101
119
  ### 2. Static Value + Interactive Handler Detection
102
120
 
@@ -114,21 +132,21 @@ Validates the **correctness** of ARIA attributes when they are used:
114
132
  </button>
115
133
  ```
116
134
 
117
- **Smart Auto-fix:** When the handler contains state variable references (e.g., `() => setExpanded(!expanded)`), the plugin automatically extracts the variable name and binds it to the ARIA attribute:
135
+ **Smart Suggestion:** When the handler contains state variable references (e.g., `() => setExpanded(!expanded)`), the plugin can suggest binding the inferred variable to the ARIA attribute:
118
136
 
119
137
  ```jsx
120
- // ❌ Before auto-fix
138
+ // ❌ Before suggestion
121
139
  <div onClick={() => setExpanded(!expanded)} aria-expanded="false">
122
140
  Click to expand
123
141
  </div>
124
142
 
125
- // ✅ After auto-fix (automatically detects 'expanded' variable)
143
+ // ✅ Suggested fix (automatically detects 'expanded' variable)
126
144
  <div onClick={() => setExpanded(!expanded)} aria-expanded={expanded ? 'true' : 'false'}>
127
145
  Click to expand
128
146
  </div>
129
147
  ```
130
148
 
131
- **Supported Patterns for Auto-fix:**
149
+ **Supported Patterns for Suggestions:**
132
150
  - `() => setX(!x)`
133
151
  - `() => setX(true)`
134
152
  - `() => setX(prev => !prev)`
@@ -141,8 +159,8 @@ Validates the **correctness** of ARIA attributes when they are used:
141
159
  // ❌ Error: role="button" with static aria-pressed
142
160
  <div role="button" aria-pressed="false">Button</div>
143
161
 
144
- // ❌ Error: role="switch" missing aria-checked
145
- <div role="switch">Toggle</div>
162
+ // ❌ Error: interactive switch role missing aria-checked
163
+ <div role="switch" onClick={toggle}>Toggle</div>
146
164
 
147
165
  // ✅ Correct: Dynamic state binding
148
166
  <div role="button" aria-pressed={isPressed ? 'true' : 'false'}>Button</div>
@@ -307,13 +325,14 @@ Elements with these roles are expected to have dynamic ARIA states:
307
325
 
308
326
  - **Base:** ESLint plugin (Node.js environment)
309
327
  - **Parser:** `@babel/eslint-parser` or TypeScript parser for JSX/TSX AST parsing
328
+ - **ARIA Data Source:** `aria-query` role/property definitions for role-attribute compatibility
310
329
  - **Analysis:** Visitor pattern on JSX elements
311
330
 
312
331
  ### Analysis Logic
313
332
 
314
333
  1. **JSXElement Traversal:** Identifies `role` and `aria-*` attributes
315
- 2. **Scope Tracking:** Uses `context.getScope()` to trace variables bound to ARIA attributes
316
- 3. **State Inference:** Determines if values derive from `useState` hooks or props
334
+ 2. **Pattern Analysis:** Detects boolean-like bindings and static-value patterns in JSX expressions
335
+ 3. **State Inference Heuristics:** Infers likely state variables from handler/setter patterns (e.g., `setX(!x)`)
317
336
  4. **Pattern Validation:** Checks for proper string conversion patterns
318
337
 
319
338
  ## Expected Benefits
@@ -324,7 +343,7 @@ Catch dynamic ARIA errors at build/CI phase instead of runtime
324
343
 
325
344
  ### 2. Developer Productivity
326
345
 
327
- Auto-fix repetitive ARIA state binding errors with `eslint --fix`
346
+ Apply targeted suggestions for repetitive ARIA state binding errors
328
347
 
329
348
  ### 3. Standardization
330
349
 
@@ -338,19 +357,13 @@ Enforce consistent dynamic ARIA usage patterns across projects
338
357
  npm test
339
358
  ```
340
359
 
341
- ### Building
342
-
343
- ```bash
344
- npm run build # If applicable
345
- ```
346
-
347
360
  ## Contributing
348
361
 
349
362
  Contributions welcome! Please feel free to submit issues or pull requests.
350
363
 
351
364
  ## License
352
365
 
353
- ISC
366
+ MIT
354
367
 
355
368
  ## Keywords
356
369
 
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const stateDependentAriaValidator = require('./lib/rules/state-dependent-aria-validator');
2
2
  const staticAriaValidator = require('./lib/rules/static-aria-validator');
3
3
 
4
- module.exports = {
4
+ const plugin = {
5
5
  rules: {
6
6
  'state-dependent-aria-validator': stateDependentAriaValidator,
7
7
  'static-aria-validator': staticAriaValidator
@@ -35,3 +35,51 @@ module.exports = {
35
35
  }
36
36
  }
37
37
  };
38
+
39
+ plugin.configs['flat/recommended'] = [
40
+ {
41
+ plugins: {
42
+ 'aria-state-validator': plugin
43
+ },
44
+ rules: {
45
+ 'aria-state-validator/state-dependent-aria-validator': 'warn',
46
+ 'aria-state-validator/static-aria-validator': 'warn'
47
+ }
48
+ }
49
+ ];
50
+
51
+ plugin.configs['flat/strict'] = [
52
+ {
53
+ plugins: {
54
+ 'aria-state-validator': plugin
55
+ },
56
+ rules: {
57
+ 'aria-state-validator/state-dependent-aria-validator': 'error',
58
+ 'aria-state-validator/static-aria-validator': 'error'
59
+ }
60
+ }
61
+ ];
62
+
63
+ plugin.configs['flat/dynamic-only'] = [
64
+ {
65
+ plugins: {
66
+ 'aria-state-validator': plugin
67
+ },
68
+ rules: {
69
+ 'aria-state-validator/state-dependent-aria-validator': 'warn'
70
+ }
71
+ }
72
+ ];
73
+
74
+ plugin.configs['flat/static-only'] = [
75
+ {
76
+ plugins: {
77
+ 'aria-state-validator': plugin
78
+ },
79
+ rules: {
80
+ 'aria-state-validator/static-aria-validator': 'warn'
81
+ }
82
+ }
83
+ ];
84
+
85
+ module.exports = plugin;
@@ -94,6 +94,29 @@ const INTERACTIVE_ROLES = [
94
94
  "listbox",
95
95
  ];
96
96
 
97
+ const ROLE_REQUIRED_DYNAMIC_ARIA = {
98
+ switch: "aria-checked",
99
+ checkbox: "aria-checked",
100
+ radio: "aria-checked",
101
+ menuitemcheckbox: "aria-checked",
102
+ menuitemradio: "aria-checked",
103
+ tab: "aria-selected",
104
+ option: "aria-selected",
105
+ treeitem: "aria-selected",
106
+ slider: "aria-valuenow",
107
+ progressbar: "aria-valuenow",
108
+ scrollbar: "aria-valuenow",
109
+ spinbutton: "aria-valuenow",
110
+ };
111
+
112
+ const DEFAULT_INTERACTIVE_HANDLERS = [
113
+ "onClick",
114
+ "onKeyDown",
115
+ "onKeyPress",
116
+ "onChange",
117
+ "onToggle",
118
+ ];
119
+
97
120
  module.exports = {
98
121
  meta: {
99
122
  type: "problem",
@@ -102,10 +125,28 @@ module.exports = {
102
125
  "Validate that ARIA state attributes are properly bound to component state",
103
126
  category: "Accessibility",
104
127
  recommended: true,
105
- url: "https://github.com/your-repo/eslint-plugin-aria-state-validator",
128
+ url: "https://github.com/comento/eslint-plugin-aria-state-validator",
106
129
  },
107
- fixable: "code",
108
- schema: [],
130
+ hasSuggestions: true,
131
+ schema: [
132
+ {
133
+ type: "object",
134
+ properties: {
135
+ interactiveHandlers: {
136
+ type: "array",
137
+ items: { type: "string" },
138
+ },
139
+ strictBooleanInference: {
140
+ type: "boolean",
141
+ },
142
+ ignorePatterns: {
143
+ type: "array",
144
+ items: { type: "string" },
145
+ },
146
+ },
147
+ additionalProperties: false,
148
+ },
149
+ ],
109
150
  messages: {
110
151
  booleanBinding:
111
152
  'ARIA attribute "{{ariaAttr}}" should not directly bind Boolean values. Convert to string.',
@@ -115,10 +156,152 @@ module.exports = {
115
156
  'Element with role="{{role}}" should have "{{ariaAttr}}" attribute bound to dynamic state.',
116
157
  invalidStaticValue:
117
158
  'Static value "{{value}}" for "{{ariaAttr}}" should be managed with dynamic state.',
159
+ suggestStringConversion:
160
+ 'Convert "{{ariaAttr}}" binding to explicit string ("true"/"false") conversion.',
161
+ suggestStateBinding:
162
+ 'Replace static "{{ariaAttr}}" value with inferred state binding.',
118
163
  },
119
164
  },
120
165
 
121
166
  create(context) {
167
+ const sourceCode = context.getSourceCode();
168
+ const options = context.options[0] || {};
169
+ const strictBooleanInference = options.strictBooleanInference !== false;
170
+ const interactiveHandlers =
171
+ Array.isArray(options.interactiveHandlers) &&
172
+ options.interactiveHandlers.length > 0
173
+ ? options.interactiveHandlers
174
+ : DEFAULT_INTERACTIVE_HANDLERS;
175
+ const ignoreRegexes = Array.isArray(options.ignorePatterns)
176
+ ? options.ignorePatterns
177
+ .map((pattern) => {
178
+ try {
179
+ return new RegExp(pattern);
180
+ } catch {
181
+ return null;
182
+ }
183
+ })
184
+ .filter(Boolean)
185
+ : [];
186
+
187
+ function matchesIgnorePattern(text) {
188
+ if (!text || ignoreRegexes.length === 0) return false;
189
+ return ignoreRegexes.some((regex) => regex.test(text));
190
+ }
191
+
192
+ function getVariableFromScope(identifierNode) {
193
+ if (!identifierNode || identifierNode.type !== "Identifier") return null;
194
+ if (!sourceCode.getScope) return null;
195
+
196
+ const scope = sourceCode.getScope(identifierNode);
197
+ let currentScope = scope;
198
+
199
+ while (currentScope) {
200
+ if (currentScope.set && currentScope.set.has(identifierNode.name)) {
201
+ return currentScope.set.get(identifierNode.name);
202
+ }
203
+ currentScope = currentScope.upper;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ function isBooleanishExpression(expr) {
210
+ if (!expr) return false;
211
+
212
+ if (expr.type === "Literal" && typeof expr.value === "boolean") {
213
+ return true;
214
+ }
215
+
216
+ if (
217
+ expr.type === "UnaryExpression" &&
218
+ (expr.operator === "!" || expr.operator === "!!")
219
+ ) {
220
+ return true;
221
+ }
222
+
223
+ if (
224
+ expr.type === "BinaryExpression" &&
225
+ ["==", "===", "!=", "!==", "<", "<=", ">", ">="].includes(expr.operator)
226
+ ) {
227
+ return true;
228
+ }
229
+
230
+ if (expr.type === "LogicalExpression") {
231
+ return true;
232
+ }
233
+
234
+ if (
235
+ expr.type === "CallExpression" &&
236
+ expr.callee.type === "Identifier" &&
237
+ expr.callee.name === "Boolean"
238
+ ) {
239
+ return true;
240
+ }
241
+
242
+ if (expr.type === "ConditionalExpression") {
243
+ return (
244
+ isBooleanishExpression(expr.consequent) &&
245
+ isBooleanishExpression(expr.alternate)
246
+ );
247
+ }
248
+
249
+ return false;
250
+ }
251
+
252
+ function isIdentifierFromBooleanState(variable, identifierName) {
253
+ if (!variable || !Array.isArray(variable.defs)) return false;
254
+
255
+ return variable.defs.some((def) => {
256
+ const declarator = def.node;
257
+ if (!declarator || declarator.type !== "VariableDeclarator") return false;
258
+
259
+ if (
260
+ declarator.id &&
261
+ declarator.id.type === "ArrayPattern" &&
262
+ declarator.init &&
263
+ declarator.init.type === "CallExpression" &&
264
+ declarator.init.callee &&
265
+ declarator.init.callee.type === "Identifier" &&
266
+ declarator.init.callee.name === "useState"
267
+ ) {
268
+ const hasIdentifier = declarator.id.elements.some(
269
+ (element) =>
270
+ element &&
271
+ element.type === "Identifier" &&
272
+ element.name === identifierName
273
+ );
274
+ if (!hasIdentifier) return false;
275
+
276
+ const firstArg = declarator.init.arguments[0];
277
+ return Boolean(
278
+ firstArg &&
279
+ ((firstArg.type === "Literal" &&
280
+ typeof firstArg.value === "boolean") ||
281
+ isBooleanishExpression(firstArg))
282
+ );
283
+ }
284
+
285
+ return Boolean(
286
+ declarator.id &&
287
+ declarator.id.type === "Identifier" &&
288
+ declarator.id.name === identifierName &&
289
+ isBooleanishExpression(declarator.init)
290
+ );
291
+ });
292
+ }
293
+
294
+ function hasBooleanLikeName(name) {
295
+ if (!name) return false;
296
+
297
+ const strictBooleanPatterns = [
298
+ /^(is|has|can|should|did|will|was|were)[A-Z_]/,
299
+ /^(open|opened|expanded|selected|checked|pressed|hidden|disabled|active|visible|loading|mounted)$/i,
300
+ ];
301
+
302
+ return strictBooleanPatterns.some((pattern) => pattern.test(name));
303
+ }
304
+
122
305
  function isStaticValue(node) {
123
306
  if (!node) return false;
124
307
 
@@ -154,7 +337,17 @@ module.exports = {
154
337
  const expression = node.expression;
155
338
 
156
339
  if (expression.type === "Identifier") {
157
- return true;
340
+ if (matchesIgnorePattern(expression.name)) {
341
+ return false;
342
+ }
343
+ if (!strictBooleanInference) {
344
+ return true;
345
+ }
346
+ const variable = getVariableFromScope(expression);
347
+ return (
348
+ hasBooleanLikeName(expression.name) ||
349
+ isIdentifierFromBooleanState(variable, expression.name)
350
+ );
158
351
  }
159
352
 
160
353
  if (expression.type === "LogicalExpression") {
@@ -212,14 +405,6 @@ module.exports = {
212
405
  }
213
406
 
214
407
  function hasInteractiveHandler(attributes) {
215
- const interactiveHandlers = [
216
- "onClick",
217
- "onKeyDown",
218
- "onKeyPress",
219
- "onChange",
220
- "onToggle",
221
- ];
222
-
223
408
  return attributes.some((attr) => {
224
409
  if (attr.type !== "JSXAttribute") return false;
225
410
  const name = attr.name.name;
@@ -325,7 +510,6 @@ module.exports = {
325
510
  }
326
511
 
327
512
  function createStringConversionFix(fixer, node) {
328
- const sourceCode = context.getSourceCode();
329
513
  const expression = node.expression;
330
514
 
331
515
  let identifierName;
@@ -354,6 +538,31 @@ module.exports = {
354
538
  const attributes = node.attributes || [];
355
539
  const hasHandler = hasInteractiveHandler(attributes);
356
540
  const role = getRoleValue(attributes);
541
+ const presentAriaAttrs = new Set(
542
+ attributes
543
+ .filter(
544
+ (attr) =>
545
+ attr.type === "JSXAttribute" &&
546
+ attr.name &&
547
+ typeof attr.name.name === "string" &&
548
+ attr.name.name.startsWith("aria-")
549
+ )
550
+ .map((attr) => attr.name.name)
551
+ );
552
+
553
+ if (role && hasHandler && ROLE_REQUIRED_DYNAMIC_ARIA[role]) {
554
+ const requiredAria = ROLE_REQUIRED_DYNAMIC_ARIA[role];
555
+ if (!presentAriaAttrs.has(requiredAria)) {
556
+ context.report({
557
+ node: node.name,
558
+ messageId: "missingStateBinding",
559
+ data: {
560
+ role,
561
+ ariaAttr: requiredAria,
562
+ },
563
+ });
564
+ }
565
+ }
357
566
 
358
567
  attributes.forEach((attr) => {
359
568
  if (attr.type !== "JSXAttribute") return;
@@ -361,8 +570,11 @@ module.exports = {
361
570
  const attrName = attr.name.name;
362
571
 
363
572
  if (!DYNAMIC_ARIA_ATTRIBUTES[attrName]) return;
573
+ if (matchesIgnorePattern(attrName)) return;
364
574
 
365
575
  const attrValue = attr.value;
576
+ if (!attrValue) return;
577
+ if (matchesIgnorePattern(sourceCode.getText(attrValue))) return;
366
578
 
367
579
  if (
368
580
  isBooleanIdentifier(attrValue) &&
@@ -376,9 +588,15 @@ module.exports = {
376
588
  data: {
377
589
  ariaAttr: attrName,
378
590
  },
379
- fix(fixer) {
380
- return createStringConversionFix(fixer, attrValue);
381
- },
591
+ suggest: [
592
+ {
593
+ messageId: "suggestStringConversion",
594
+ data: { ariaAttr: attrName },
595
+ fix(fixer) {
596
+ return createStringConversionFix(fixer, attrValue);
597
+ },
598
+ },
599
+ ],
382
600
  });
383
601
  return;
384
602
  }
@@ -387,7 +605,7 @@ module.exports = {
387
605
  const handlers = attributes.filter(
388
606
  (a) =>
389
607
  a.type === "JSXAttribute" &&
390
- ["onClick", "onKeyDown", "onChange"].includes(a.name.name)
608
+ interactiveHandlers.includes(a.name.name)
391
609
  );
392
610
 
393
611
  const handlerNames = handlers.map((a) => a.name.name);
@@ -398,6 +616,9 @@ module.exports = {
398
616
  stateVariable = extractStateVariableFromHandler(handler);
399
617
  if (stateVariable) break;
400
618
  }
619
+ if (matchesIgnorePattern(stateVariable)) {
620
+ stateVariable = null;
621
+ }
401
622
 
402
623
  context.report({
403
624
  node: attr,
@@ -406,13 +627,19 @@ module.exports = {
406
627
  ariaAttr: attrName,
407
628
  handler: handlerNames.join(", "),
408
629
  },
409
- fix: stateVariable
410
- ? (fixer) => {
411
- return fixer.replaceText(
412
- attrValue,
413
- `{${stateVariable} ? 'true' : 'false'}`
414
- );
415
- }
630
+ suggest: stateVariable
631
+ ? [
632
+ {
633
+ messageId: "suggestStateBinding",
634
+ data: { ariaAttr: attrName },
635
+ fix(fixer) {
636
+ return fixer.replaceText(
637
+ attrValue,
638
+ `{${stateVariable} ? 'true' : 'false'}`
639
+ );
640
+ },
641
+ },
642
+ ]
416
643
  : undefined,
417
644
  });
418
645
  return;
@@ -5,6 +5,8 @@
5
5
 
6
6
  "use strict";
7
7
 
8
+ const { roles: ARIA_ROLES } = require("aria-query");
9
+
8
10
  // Valid ARIA properties according to ARIA 1.2 spec
9
11
  const VALID_ARIA_PROPS = new Set([
10
12
  // Widget attributes
@@ -86,7 +88,7 @@ const ARIA_TYPOS = {
86
88
  "aria-check": "aria-checked",
87
89
  };
88
90
 
89
- // Roles and their required/allowed attributes
91
+ // Fallback role definitions when aria-query data is unavailable for a role.
90
92
  const ROLE_DEFINITIONS = {
91
93
  checkbox: {
92
94
  required: ["aria-checked"],
@@ -178,7 +180,7 @@ module.exports = {
178
180
  description: "Validate static ARIA attributes for correctness",
179
181
  category: "Accessibility",
180
182
  recommended: true,
181
- url: "https://github.com/your-repo/eslint-plugin-aria-state-validator",
183
+ url: "https://github.com/comento/eslint-plugin-aria-state-validator",
182
184
  },
183
185
  fixable: "code",
184
186
  schema: [],
@@ -292,10 +294,38 @@ module.exports = {
292
294
  role: roleValue,
293
295
  element: elementName,
294
296
  },
297
+ fix(fixer) {
298
+ const [start, end] = roleAttr.range;
299
+ const removeStart =
300
+ start > 0 && sourceCode.text[start - 1] === " " ? start - 1 : start;
301
+ return fixer.removeRange([removeStart, end]);
302
+ },
295
303
  });
296
304
  }
297
305
  }
298
306
 
307
+ function getRoleSpec(roleValue) {
308
+ if (!roleValue) return null;
309
+
310
+ const ariaRole = ARIA_ROLES.get(roleValue);
311
+ if (ariaRole) {
312
+ return {
313
+ required: Object.keys(ariaRole.requiredProps || {}),
314
+ allowedSet: new Set(Object.keys(ariaRole.props || {})),
315
+ };
316
+ }
317
+
318
+ if (ROLE_DEFINITIONS[roleValue]) {
319
+ const fallback = ROLE_DEFINITIONS[roleValue];
320
+ return {
321
+ required: fallback.required,
322
+ allowedSet: new Set([...fallback.required, ...fallback.allowed]),
323
+ };
324
+ }
325
+
326
+ return null;
327
+ }
328
+
299
329
  return {
300
330
  JSXOpeningElement(node) {
301
331
  const attributes = node.attributes || [];
@@ -308,10 +338,11 @@ module.exports = {
308
338
  (attr) => attr.type === "JSXAttribute" && attr.name.name === "role"
309
339
  );
310
340
  const roleValue = getAttributeValue(roleAttr);
341
+ const roleSpec = getRoleSpec(roleValue);
311
342
 
312
343
  // Required ARIA attributes for role
313
- if (roleValue && ROLE_DEFINITIONS[roleValue]) {
314
- const required = ROLE_DEFINITIONS[roleValue].required;
344
+ if (roleValue && roleSpec) {
345
+ const required = roleSpec.required;
315
346
  const present = attributes
316
347
  .filter((attr) => attr.type === "JSXAttribute" && attr.name.name.startsWith("aria-"))
317
348
  .map((attr) => attr.name.name);
@@ -388,22 +419,14 @@ module.exports = {
388
419
  return;
389
420
  }
390
421
 
391
- // Check for role-attribute compatibility
392
- if (roleValue && ROLE_DEFINITIONS[roleValue]) {
393
- const definition = ROLE_DEFINITIONS[roleValue];
394
- const allAllowed = [...definition.required, ...definition.allowed, "aria-label", "aria-labelledby", "aria-describedby", "aria-hidden", "aria-live", "aria-atomic", "aria-busy"];
395
-
396
- if (!allAllowed.includes(attrName) && !attrName.startsWith("aria-own") && !attrName.startsWith("aria-control")) {
397
- // This is a bit simplified, but good for catching obvious mismatches
398
- // e.g., role="img" with aria-expanded
399
- if (roleValue === "img" && attrName === "aria-expanded") {
400
- context.report({
401
- node: attr,
402
- messageId: "disallowedAriaForRole",
403
- data: { prop: attrName, role: roleValue },
404
- });
405
- }
406
- }
422
+ // Check role-attribute compatibility from aria-query role props.
423
+ if (roleValue && roleSpec && roleSpec.allowedSet.size > 0 && !roleSpec.allowedSet.has(attrName)) {
424
+ context.report({
425
+ node: attr,
426
+ messageId: "disallowedAriaForRole",
427
+ data: { prop: attrName, role: roleValue },
428
+ });
429
+ return;
407
430
  }
408
431
 
409
432
  const value = getAttributeValue(attr);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-aria-state-validator",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Comprehensive ESLint plugin for ARIA accessibility validation: dynamic state binding and static ARIA attributes correctness",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -50,8 +50,12 @@
50
50
  "devDependencies": {
51
51
  "@babel/eslint-parser": "^7.28.5",
52
52
  "@babel/preset-react": "^7.28.5",
53
+ "@typescript-eslint/parser": "^8.57.2",
53
54
  "eslint": "^9.39.1",
54
55
  "eslint-plugin-react": "^7.37.5",
55
56
  "jest": "^30.2.0"
57
+ },
58
+ "dependencies": {
59
+ "aria-query": "^5.3.2"
56
60
  }
57
61
  }