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 +52 -37
- package/README.md +38 -25
- package/index.js +49 -1
- package/lib/rules/state-dependent-aria-validator.js +251 -24
- package/lib/rules/static-aria-validator.js +43 -20
- package/package.json +5 -1
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...ariaStateValidator.configs
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
141
|
+
**제안 제공:** 규칙이 문자열 형식(`'true'/'false'`)으로 바꾸는 안전한 제안을 제공합니다.
|
|
122
142
|
|
|
123
143
|
### 2. 정적 값 + 인터랙티브 핸들러 감지
|
|
124
144
|
|
|
@@ -136,21 +156,21 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
|
|
|
136
156
|
</button>
|
|
137
157
|
```
|
|
138
158
|
|
|
139
|
-
**스마트
|
|
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
|
-
// ✅
|
|
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
|
-
// ❌ 오류:
|
|
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.
|
|
338
|
-
3. **상태
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
138
|
+
// ❌ Before suggestion
|
|
121
139
|
<div onClick={() => setExpanded(!expanded)} aria-expanded="false">
|
|
122
140
|
Click to expand
|
|
123
141
|
</div>
|
|
124
142
|
|
|
125
|
-
// ✅
|
|
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
|
|
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:
|
|
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. **
|
|
316
|
-
3. **State Inference:**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
128
|
+
url: "https://github.com/comento/eslint-plugin-aria-state-validator",
|
|
106
129
|
},
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
?
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
//
|
|
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/
|
|
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 &&
|
|
314
|
-
const 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
|
|
392
|
-
if (roleValue &&
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
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
|
}
|