eslint-plugin-aria-state-validator 1.0.0 → 1.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/README.ko.md CHANGED
@@ -93,10 +93,12 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
93
93
 
94
94
  사용된 ARIA 속성의 **정확성**을 검증합니다:
95
95
 
96
- - 유효한 ARIA 속성 이름 (오타 감지)
97
- - Boolean 및 Enum ARIA 속성의 올바른 값
98
- - 충돌하는 ARIA 속성 감지
99
- - 네이티브 HTML 요소의 중복 role 감지
96
+ - 유효한 ARIA 속성 이름 (오타 감지 및 **자동 수정**)
97
+ - Boolean 및 Enum ARIA 속성의 올바른 값 (**자동 수정** 지원)
98
+ - 충돌하는 ARIA 속성 감지 (**자동 수정** 지원)
99
+ - 네이티브 HTML 요소의 중복 role 감지 (**자동 수정** 지원)
100
+ - **역할별 필수 ARIA 속성** 검증 (예: `switch` 역할의 `aria-checked` 필수 사용)
101
+ - **역할-속성 호환성** 검증 (예: `img` 역할에 `aria-expanded` 사용 방지)
100
102
 
101
103
  **참고:** 이 플러그인은 ARIA 속성을 **어떻게** 사용하는지를 검증하며, **사용 여부**를 강제하지는 않습니다. 접근성 기능의 존재를 강제하려면 [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y)를 사용하세요.
102
104
 
@@ -148,16 +150,25 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
148
150
  </div>
149
151
  ```
150
152
 
153
+ **자동 수정 지원 패턴:**
154
+ - `() => setX(!x)`
155
+ - `() => setX(true)`
156
+ - `() => setX(prev => !prev)`
157
+
151
158
  ### 3. 역할 기반 상태 연관성 검증
152
159
 
153
- **감지 내용:** 정적 ARIA 상태를 가진 인터랙티브 역할
160
+ **감지 내용:** 인터랙티브 역할(Role)에 정적인 ARIA 상태를 사용하거나 필수 속성이 누락된 경우.
154
161
 
155
162
  ```jsx
156
163
  // ❌ 오류: role="button"인데 aria-pressed가 정적
157
164
  <div role="button" aria-pressed="false">버튼</div>
158
165
 
166
+ // ❌ 오류: role="switch"에 필수 속성 aria-checked 누락
167
+ <div role="switch">토글</div>
168
+
159
169
  // ✅ 올바름: 동적 상태 바인딩
160
170
  <div role="button" aria-pressed={isPressed ? 'true' : 'false'}>버튼</div>
171
+ <div role="switch" aria-checked={isChecked ? 'true' : 'false'}>토글</div>
161
172
  ```
162
173
 
163
174
  ---
@@ -193,7 +204,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
193
204
  **감지 내용:** 잘못된 boolean 형식 값
194
205
 
195
206
  ```jsx
196
- // ❌ 오류: 유효하지 않은 boolean 값
207
+ // ❌ 오류: 유효하지 않은 boolean 값 ("yes"/"no"는 자동 수정 지원)
197
208
  <div aria-hidden="yes">내용</div>
198
209
  <div aria-disabled="1">내용</div>
199
210
 
@@ -252,12 +263,15 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
252
263
 
253
264
  - `aria-expanded` - 토글 버튼, 확장 가능한 요소
254
265
  - `aria-selected` - 탭, 선택 가능한 옵션
255
- - `aria-checked` - 체크박스, 라디오 버튼
266
+ - `aria-checked` - 체크박스, 라디오 버튼, 스위치
256
267
  - `aria-pressed` - 토글 버튼
257
268
  - `aria-hidden` - 동적으로 표시/숨김되는 요소
258
269
  - `aria-disabled` - 동적으로 활성화/비활성화되는 요소
259
270
  - `aria-modal` - 다이얼로그, 모달
260
271
  - `aria-current` - 현재 활성 항목 표시
272
+ - `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - 수치 입력 위젯 (슬라이더, 프로그레스 바 등)
273
+ - `aria-level` - 계층 구조 레벨
274
+ - `aria-posinset`, `aria-setsize` - 세트 내 위치 및 전체 크기
261
275
 
262
276
  ## 지원하는 인터랙티브 역할
263
277
 
@@ -265,7 +279,7 @@ ARIA **상태** 속성이 컴포넌트 상태와 올바르게 연결되어 동
265
279
 
266
280
  - `button`, `tab`, `checkbox`, `radio`, `switch`
267
281
  - `menuitem`, `menuitemcheckbox`, `menuitemradio`
268
- - `option`, `treeitem`
282
+ - `option`, `treeitem`, `slider`, `progressbar`
269
283
  - `dialog`, `alertdialog`
270
284
 
271
285
  ## 예제
package/README.md CHANGED
@@ -73,10 +73,12 @@ Validates that ARIA **state** attributes are properly bound to component state a
73
73
  ### Rule 2: `static-aria-validator` (Static ARIA Validation)
74
74
 
75
75
  Validates the **correctness** of ARIA attributes when they are used:
76
- - Valid ARIA property names (catches typos)
77
- - Correct values for boolean and enum ARIA attributes
78
- - No conflicting ARIA attributes
79
- - No redundant roles on native HTML elements
76
+ - Valid ARIA property names (catches typos with **auto-fix**)
77
+ - Correct values for boolean and enum ARIA attributes (**auto-fix** for "yes"/"no")
78
+ - No conflicting ARIA attributes (**auto-fix** available)
79
+ - No redundant roles on native HTML elements (**auto-fix** available)
80
+ - **Required ARIA attributes** based on roles (e.g., `aria-checked` for `switch`)
81
+ - **Role-attribute compatibility** (e.g., prevents `aria-expanded` on `img`)
80
82
 
81
83
  **Note:** This plugin validates **how** ARIA attributes are used, not **whether** they should be used. For enforcing the presence of accessibility features, use [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y).
82
84
 
@@ -126,16 +128,25 @@ Validates the **correctness** of ARIA attributes when they are used:
126
128
  </div>
127
129
  ```
128
130
 
131
+ **Supported Patterns for Auto-fix:**
132
+ - `() => setX(!x)`
133
+ - `() => setX(true)`
134
+ - `() => setX(prev => !prev)`
135
+
129
136
  ### 3. Role-Based State Association Validation
130
137
 
131
- **Detects:** Interactive roles with static ARIA states
138
+ **Detects:** Interactive roles with static ARIA states or missing required states.
132
139
 
133
140
  ```jsx
134
141
  // ❌ Error: role="button" with static aria-pressed
135
142
  <div role="button" aria-pressed="false">Button</div>
136
143
 
144
+ // ❌ Error: role="switch" missing aria-checked
145
+ <div role="switch">Toggle</div>
146
+
137
147
  // ✅ Correct: Dynamic state binding
138
148
  <div role="button" aria-pressed={isPressed ? 'true' : 'false'}>Button</div>
149
+ <div role="switch" aria-checked={isChecked ? 'true' : 'false'}>Toggle</div>
139
150
  ```
140
151
 
141
152
  ---
@@ -150,7 +161,7 @@ Validates the **correctness** of ARIA attributes when they are used:
150
161
  // ❌ Error: Typo in aria-label
151
162
  <button aria-labell="Close">X</button>
152
163
 
153
- // ✅ Correct: Proper spelling
164
+ // ✅ Correct (Auto-fixed): Proper spelling
154
165
  <button aria-label="Close">X</button>
155
166
  ```
156
167
 
@@ -171,7 +182,7 @@ Validates the **correctness** of ARIA attributes when they are used:
171
182
  **Detects:** Incorrect boolean-like values
172
183
 
173
184
  ```jsx
174
- // ❌ Error: Invalid boolean value
185
+ // ❌ Error: Invalid boolean value (Auto-fix available for "yes"/"no")
175
186
  <div aria-hidden="yes">Content</div>
176
187
  <div aria-disabled="1">Content</div>
177
188
 
@@ -230,12 +241,15 @@ The plugin validates these dynamic ARIA state attributes:
230
241
 
231
242
  - `aria-expanded` - Toggle buttons, expandable elements
232
243
  - `aria-selected` - Tabs, selectable options
233
- - `aria-checked` - Checkboxes, radio buttons
244
+ - `aria-checked` - Checkboxes, radio buttons, switches
234
245
  - `aria-pressed` - Toggle buttons
235
246
  - `aria-hidden` - Dynamically shown/hidden elements
236
247
  - `aria-disabled` - Dynamically enabled/disabled elements
237
248
  - `aria-modal` - Dialogs, modals
238
249
  - `aria-current` - Current active item indicators
250
+ - `aria-valuenow`, `aria-valuemin`, `aria-valuemax` - Range widgets (sliders, progress bars)
251
+ - `aria-level` - Hierarchical level
252
+ - `aria-posinset`, `aria-setsize` - Set position and size
239
253
 
240
254
  ## Supported Interactive Roles
241
255
 
@@ -243,7 +257,7 @@ Elements with these roles are expected to have dynamic ARIA states:
243
257
 
244
258
  - `button`, `tab`, `checkbox`, `radio`, `switch`
245
259
  - `menuitem`, `menuitemcheckbox`, `menuitemradio`
246
- - `option`, `treeitem`
260
+ - `option`, `treeitem`, `slider`, `progressbar`
247
261
  - `dialog`, `alertdialog`
248
262
 
249
263
  ## Examples
@@ -46,6 +46,30 @@ const DYNAMIC_ARIA_ATTRIBUTES = {
46
46
  validValues: ["page", "step", "location", "date", "time", "true", "false"],
47
47
  description: "Current active item indicator",
48
48
  },
49
+ "aria-valuenow": {
50
+ requiresState: true,
51
+ description: "Current value for range widgets",
52
+ },
53
+ "aria-valuemin": {
54
+ requiresState: true,
55
+ description: "Minimum value for range widgets",
56
+ },
57
+ "aria-valuemax": {
58
+ requiresState: true,
59
+ description: "Maximum value for range widgets",
60
+ },
61
+ "aria-level": {
62
+ requiresState: true,
63
+ description: "Hierarchical level of an element",
64
+ },
65
+ "aria-posinset": {
66
+ requiresState: true,
67
+ description: "Position of an element in a set",
68
+ },
69
+ "aria-setsize": {
70
+ requiresState: true,
71
+ description: "Total size of a set",
72
+ },
49
73
  };
50
74
 
51
75
  const INTERACTIVE_ROLES = [
@@ -61,6 +85,13 @@ const INTERACTIVE_ROLES = [
61
85
  "treeitem",
62
86
  "dialog",
63
87
  "alertdialog",
88
+ "slider",
89
+ "progressbar",
90
+ "scrollbar",
91
+ "spinbutton",
92
+ "tree",
93
+ "grid",
94
+ "listbox",
64
95
  ];
65
96
 
66
97
  module.exports = {
@@ -224,51 +255,73 @@ module.exports = {
224
255
 
225
256
  const expression = handlerAttr.value.expression;
226
257
 
227
- // Arrow function or function expression
258
+ // Direct call or arrow function/function expression
259
+ let functionBody = null;
228
260
  if (
229
261
  expression.type === "ArrowFunctionExpression" ||
230
262
  expression.type === "FunctionExpression"
231
263
  ) {
232
- const body = expression.body;
233
-
234
- // Find setState calls in the body
235
- let stateVariable = null;
236
-
237
- // Simple case: () => setX(!x) or () => setX(prev => !prev)
238
- if (body.type === "CallExpression") {
239
- // Look for argument that might be state variable
240
- if (body.arguments.length > 0) {
241
- const arg = body.arguments[0];
242
- if (arg.type === "UnaryExpression" && arg.operator === "!") {
243
- if (arg.argument.type === "Identifier") {
244
- stateVariable = arg.argument.name;
245
- }
264
+ functionBody = expression.body;
265
+ }
266
+
267
+ if (!functionBody) return null;
268
+
269
+ // Find setState calls in the body
270
+ let stateVariable = null;
271
+
272
+ const checkCallExpression = (callExpr) => {
273
+ if (callExpr.type !== "CallExpression") return null;
274
+
275
+ // Simple case: setX(!x), setX(true), setX(false)
276
+ if (callExpr.arguments.length > 0) {
277
+ const arg = callExpr.arguments[0];
278
+
279
+ // Pattern: !x
280
+ if (arg.type === "UnaryExpression" && arg.operator === "!") {
281
+ if (arg.argument.type === "Identifier") {
282
+ return arg.argument.name;
283
+ }
284
+ }
285
+
286
+ // Pattern: boolean literal (true/false)
287
+ if (arg.type === "Literal" && typeof arg.value === "boolean") {
288
+ // We can't easily know which state it's setting from just 'true',
289
+ // but we can look at the setter name if it follows 'setX' pattern
290
+ const callee = callExpr.callee;
291
+ if (callee.type === "Identifier" && callee.name.startsWith("set")) {
292
+ const varName = callee.name.slice(3);
293
+ return varName.charAt(0).toLowerCase() + varName.slice(1);
246
294
  }
247
295
  }
248
- }
249
296
 
250
- // Block statement case: () => { setX(!x); }
251
- if (body.type === "BlockStatement" && body.body.length > 0) {
252
- for (const stmt of body.body) {
253
- if (stmt.type === "ExpressionStatement") {
254
- const expr = stmt.expression;
255
- if (expr.type === "CallExpression" && expr.arguments.length > 0) {
256
- const arg = expr.arguments[0];
257
- if (arg.type === "UnaryExpression" && arg.operator === "!") {
258
- if (arg.argument.type === "Identifier") {
259
- stateVariable = arg.argument.name;
260
- break;
261
- }
262
- }
263
- }
297
+ // Pattern: prev => !prev (functional update)
298
+ if (arg.type === "ArrowFunctionExpression" && arg.body.type === "UnaryExpression" && arg.body.operator === "!") {
299
+ const callee = callExpr.callee;
300
+ if (callee.type === "Identifier" && callee.name.startsWith("set")) {
301
+ const varName = callee.name.slice(3);
302
+ return varName.charAt(0).toLowerCase() + varName.slice(1);
264
303
  }
265
304
  }
266
305
  }
306
+ return null;
307
+ };
267
308
 
268
- return stateVariable;
309
+ // Simple case: () => setX(!x)
310
+ if (functionBody.type === "CallExpression") {
311
+ stateVariable = checkCallExpression(functionBody);
269
312
  }
270
313
 
271
- return null;
314
+ // Block statement case: () => { setX(!x); }
315
+ if (functionBody.type === "BlockStatement" && functionBody.body.length > 0) {
316
+ for (const stmt of functionBody.body) {
317
+ if (stmt.type === "ExpressionStatement") {
318
+ stateVariable = checkCallExpression(stmt.expression);
319
+ if (stateVariable) break;
320
+ }
321
+ }
322
+ }
323
+
324
+ return stateVariable;
272
325
  }
273
326
 
274
327
  function createStringConversionFix(fixer, node) {
@@ -313,7 +366,9 @@ module.exports = {
313
366
 
314
367
  if (
315
368
  isBooleanIdentifier(attrValue) &&
316
- !hasProperStringConversion(attrValue)
369
+ !hasProperStringConversion(attrValue) &&
370
+ DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues &&
371
+ (DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("true") || DYNAMIC_ARIA_ATTRIBUTES[attrName].validValues.includes("false"))
317
372
  ) {
318
373
  context.report({
319
374
  node: attr,
@@ -76,6 +76,56 @@ const REQUIRES_VALUE = new Set([
76
76
  "aria-flowto",
77
77
  ]);
78
78
 
79
+ // Common ARIA typos and their correct versions
80
+ const ARIA_TYPOS = {
81
+ "aria-labell": "aria-label",
82
+ "aria-hidde": "aria-hidden",
83
+ "aria-expect": "aria-expanded",
84
+ "aria-describeb": "aria-describedby",
85
+ "aria-control": "aria-controls",
86
+ "aria-check": "aria-checked",
87
+ };
88
+
89
+ // Roles and their required/allowed attributes
90
+ const ROLE_DEFINITIONS = {
91
+ checkbox: {
92
+ required: ["aria-checked"],
93
+ allowed: ["aria-readonly", "aria-disabled"],
94
+ },
95
+ radio: {
96
+ required: ["aria-checked"],
97
+ allowed: ["aria-disabled"],
98
+ },
99
+ combobox: {
100
+ required: ["aria-controls", "aria-expanded"],
101
+ allowed: ["aria-haspopup", "aria-autocomplete", "aria-required"],
102
+ },
103
+ tab: {
104
+ required: ["aria-selected"],
105
+ allowed: ["aria-disabled"],
106
+ },
107
+ slider: {
108
+ required: ["aria-valuenow"],
109
+ allowed: ["aria-valuemin", "aria-valuemax", "aria-orientation", "aria-readonly"],
110
+ },
111
+ switch: {
112
+ required: ["aria-checked"],
113
+ allowed: ["aria-readonly", "aria-disabled"],
114
+ },
115
+ scrollbar: {
116
+ required: ["aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-orientation"],
117
+ allowed: ["aria-controls"],
118
+ },
119
+ heading: {
120
+ required: ["aria-level"],
121
+ allowed: [],
122
+ },
123
+ img: {
124
+ required: [],
125
+ allowed: ["aria-label", "aria-labelledby"],
126
+ },
127
+ };
128
+
79
129
  // Valid values for boolean-like ARIA attributes
80
130
  const BOOLEAN_ARIA_VALUES = new Set(["true", "false"]);
81
131
 
@@ -130,7 +180,7 @@ module.exports = {
130
180
  recommended: true,
131
181
  url: "https://github.com/your-repo/eslint-plugin-aria-state-validator",
132
182
  },
133
- fixable: null,
183
+ fixable: "code",
134
184
  schema: [],
135
185
  messages: {
136
186
  invalidAriaProp:
@@ -145,10 +195,17 @@ module.exports = {
145
195
  "Do not use both aria-label and aria-labelledby. aria-labelledby takes precedence.",
146
196
  redundantRole:
147
197
  'Redundant role "{{role}}" on <{{element}}>. Native HTML semantics already convey this role.',
198
+ missingRequiredAria:
199
+ 'Role "{{role}}" requires the following ARIA attributes: {{missing}}.',
200
+ disallowedAriaForRole:
201
+ 'ARIA attribute "{{prop}}" is not recommended for role "{{role}}".',
202
+ ariaTypo:
203
+ 'Invalid ARIA attribute "{{prop}}". Did you mean "{{correct}}"?',
148
204
  },
149
205
  },
150
206
 
151
207
  create(context) {
208
+ const sourceCode = context.getSourceCode();
152
209
  // Elements with implicit roles
153
210
  const IMPLICIT_ROLES = {
154
211
  a: "link", // when href present
@@ -170,7 +227,7 @@ module.exports = {
170
227
  };
171
228
 
172
229
  function getAttributeValue(attr) {
173
- if (!attr.value) return null;
230
+ if (!attr || !attr.value) return null;
174
231
 
175
232
  if (attr.value.type === "Literal") {
176
233
  return attr.value.value;
@@ -242,10 +299,36 @@ module.exports = {
242
299
  return {
243
300
  JSXOpeningElement(node) {
244
301
  const attributes = node.attributes || [];
302
+ const elementName = node.name.name;
245
303
 
246
304
  // Check for redundant roles
247
305
  checkRedundantRole(node);
248
306
 
307
+ const roleAttr = attributes.find(
308
+ (attr) => attr.type === "JSXAttribute" && attr.name.name === "role"
309
+ );
310
+ const roleValue = getAttributeValue(roleAttr);
311
+
312
+ // Required ARIA attributes for role
313
+ if (roleValue && ROLE_DEFINITIONS[roleValue]) {
314
+ const required = ROLE_DEFINITIONS[roleValue].required;
315
+ const present = attributes
316
+ .filter((attr) => attr.type === "JSXAttribute" && attr.name.name.startsWith("aria-"))
317
+ .map((attr) => attr.name.name);
318
+
319
+ const missing = required.filter((req) => !present.includes(req));
320
+ if (missing.length > 0) {
321
+ context.report({
322
+ node: roleAttr || node.name,
323
+ messageId: "missingRequiredAria",
324
+ data: {
325
+ role: roleValue,
326
+ missing: missing.join(", "),
327
+ },
328
+ });
329
+ }
330
+ }
331
+
249
332
  // Check for conflicting aria-label and aria-labelledby
250
333
  const hasAriaLabel = attributes.some(
251
334
  (attr) =>
@@ -264,6 +347,9 @@ module.exports = {
264
347
  context.report({
265
348
  node: ariaLabelAttr,
266
349
  messageId: "ariaLabelWithLabelledBy",
350
+ fix(fixer) {
351
+ return fixer.remove(ariaLabelAttr);
352
+ }
267
353
  });
268
354
  }
269
355
 
@@ -273,6 +359,22 @@ module.exports = {
273
359
 
274
360
  const attrName = attr.name.name;
275
361
 
362
+ // Check for typos in aria- attributes
363
+ if (attrName.startsWith("aria-") && !VALID_ARIA_PROPS.has(attrName)) {
364
+ const correct = ARIA_TYPOS[attrName];
365
+ if (correct) {
366
+ context.report({
367
+ node: attr.name,
368
+ messageId: "ariaTypo",
369
+ data: { prop: attrName, correct: correct },
370
+ fix(fixer) {
371
+ return fixer.replaceText(attr.name, correct);
372
+ }
373
+ });
374
+ return;
375
+ }
376
+ }
377
+
276
378
  // Only check aria-* attributes
277
379
  if (!attrName.startsWith("aria-")) return;
278
380
 
@@ -286,6 +388,24 @@ module.exports = {
286
388
  return;
287
389
  }
288
390
 
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
+ }
407
+ }
408
+
289
409
  const value = getAttributeValue(attr);
290
410
 
291
411
  // Can't validate dynamic values
@@ -331,6 +451,11 @@ module.exports = {
331
451
  node: attr,
332
452
  messageId: "invalidBooleanValue",
333
453
  data: { prop: attrName, value: String(value) },
454
+ fix(fixer) {
455
+ if (value === "yes" || value === "1") return fixer.replaceText(attr.value, '"true"');
456
+ if (value === "no" || value === "0") return fixer.replaceText(attr.value, '"false"');
457
+ return null;
458
+ }
334
459
  });
335
460
  return;
336
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-aria-state-validator",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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": [