eslint-plugin-aria-state-validator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +382 -0
- package/README.md +360 -0
- package/index.js +37 -0
- package/lib/rules/state-dependent-aria-validator.js +385 -0
- package/lib/rules/static-aria-validator.js +358 -0
- package/package.json +57 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview ESLint rule to validate that ARIA state attributes are properly bound to component state
|
|
3
|
+
* @author eslint-plugin-aria-state-validator
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const DYNAMIC_ARIA_ATTRIBUTES = {
|
|
9
|
+
"aria-expanded": {
|
|
10
|
+
requiresState: true,
|
|
11
|
+
validValues: ["true", "false"],
|
|
12
|
+
description: "Toggle button or expandable element",
|
|
13
|
+
},
|
|
14
|
+
"aria-selected": {
|
|
15
|
+
requiresState: true,
|
|
16
|
+
validValues: ["true", "false"],
|
|
17
|
+
description: "Tab or Option selectable element",
|
|
18
|
+
},
|
|
19
|
+
"aria-checked": {
|
|
20
|
+
requiresState: true,
|
|
21
|
+
validValues: ["true", "false", "mixed"],
|
|
22
|
+
description: "Checkbox or Radio button",
|
|
23
|
+
},
|
|
24
|
+
"aria-pressed": {
|
|
25
|
+
requiresState: true,
|
|
26
|
+
validValues: ["true", "false", "mixed"],
|
|
27
|
+
description: "Toggle button",
|
|
28
|
+
},
|
|
29
|
+
"aria-hidden": {
|
|
30
|
+
requiresState: true,
|
|
31
|
+
validValues: ["true", "false"],
|
|
32
|
+
description: "Dynamically shown or hidden element",
|
|
33
|
+
},
|
|
34
|
+
"aria-disabled": {
|
|
35
|
+
requiresState: true,
|
|
36
|
+
validValues: ["true", "false"],
|
|
37
|
+
description: "Dynamically enabled/disabled element",
|
|
38
|
+
},
|
|
39
|
+
"aria-modal": {
|
|
40
|
+
requiresState: true,
|
|
41
|
+
validValues: ["true", "false"],
|
|
42
|
+
description: "Dialog or Modal",
|
|
43
|
+
},
|
|
44
|
+
"aria-current": {
|
|
45
|
+
requiresState: true,
|
|
46
|
+
validValues: ["page", "step", "location", "date", "time", "true", "false"],
|
|
47
|
+
description: "Current active item indicator",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const INTERACTIVE_ROLES = [
|
|
52
|
+
"button",
|
|
53
|
+
"tab",
|
|
54
|
+
"checkbox",
|
|
55
|
+
"radio",
|
|
56
|
+
"switch",
|
|
57
|
+
"menuitem",
|
|
58
|
+
"menuitemcheckbox",
|
|
59
|
+
"menuitemradio",
|
|
60
|
+
"option",
|
|
61
|
+
"treeitem",
|
|
62
|
+
"dialog",
|
|
63
|
+
"alertdialog",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
meta: {
|
|
68
|
+
type: "problem",
|
|
69
|
+
docs: {
|
|
70
|
+
description:
|
|
71
|
+
"Validate that ARIA state attributes are properly bound to component state",
|
|
72
|
+
category: "Accessibility",
|
|
73
|
+
recommended: true,
|
|
74
|
+
url: "https://github.com/your-repo/eslint-plugin-aria-state-validator",
|
|
75
|
+
},
|
|
76
|
+
fixable: "code",
|
|
77
|
+
schema: [],
|
|
78
|
+
messages: {
|
|
79
|
+
booleanBinding:
|
|
80
|
+
'ARIA attribute "{{ariaAttr}}" should not directly bind Boolean values. Convert to string.',
|
|
81
|
+
staticValueWithHandler:
|
|
82
|
+
'Element with interactive handler ({{handler}}) has static "{{ariaAttr}}" attribute. Should be bound to dynamic state.',
|
|
83
|
+
missingStateBinding:
|
|
84
|
+
'Element with role="{{role}}" should have "{{ariaAttr}}" attribute bound to dynamic state.',
|
|
85
|
+
invalidStaticValue:
|
|
86
|
+
'Static value "{{value}}" for "{{ariaAttr}}" should be managed with dynamic state.',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
create(context) {
|
|
91
|
+
function isStaticValue(node) {
|
|
92
|
+
if (!node) return false;
|
|
93
|
+
|
|
94
|
+
if (node.type === "JSXExpressionContainer") {
|
|
95
|
+
const expression = node.expression;
|
|
96
|
+
|
|
97
|
+
if (expression.type === "Literal") {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
expression.type === "TemplateLiteral" &&
|
|
103
|
+
expression.expressions.length === 0
|
|
104
|
+
) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (node.type === "Literal") {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isBooleanIdentifier(node) {
|
|
119
|
+
if (!node || node.type !== "JSXExpressionContainer") {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const expression = node.expression;
|
|
124
|
+
|
|
125
|
+
if (expression.type === "Identifier") {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (expression.type === "LogicalExpression") {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
expression.type === "UnaryExpression" &&
|
|
135
|
+
expression.operator === "!"
|
|
136
|
+
) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasProperStringConversion(node) {
|
|
144
|
+
if (!node || node.type !== "JSXExpressionContainer") {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const expression = node.expression;
|
|
149
|
+
|
|
150
|
+
if (expression.type === "ConditionalExpression") {
|
|
151
|
+
const consequent = expression.consequent;
|
|
152
|
+
const alternate = expression.alternate;
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
consequent.type === "Literal" &&
|
|
156
|
+
typeof consequent.value === "string" &&
|
|
157
|
+
alternate.type === "Literal" &&
|
|
158
|
+
typeof alternate.value === "string"
|
|
159
|
+
) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
expression.type === "CallExpression" &&
|
|
166
|
+
expression.callee.type === "Identifier" &&
|
|
167
|
+
expression.callee.name === "String"
|
|
168
|
+
) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
expression.type === "CallExpression" &&
|
|
174
|
+
expression.callee.type === "MemberExpression" &&
|
|
175
|
+
expression.callee.property.name === "toString"
|
|
176
|
+
) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hasInteractiveHandler(attributes) {
|
|
184
|
+
const interactiveHandlers = [
|
|
185
|
+
"onClick",
|
|
186
|
+
"onKeyDown",
|
|
187
|
+
"onKeyPress",
|
|
188
|
+
"onChange",
|
|
189
|
+
"onToggle",
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
return attributes.some((attr) => {
|
|
193
|
+
if (attr.type !== "JSXAttribute") return false;
|
|
194
|
+
const name = attr.name.name;
|
|
195
|
+
return interactiveHandlers.includes(name);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getRoleValue(attributes) {
|
|
200
|
+
const roleAttr = attributes.find(
|
|
201
|
+
(attr) => attr.type === "JSXAttribute" && attr.name.name === "role"
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (!roleAttr || !roleAttr.value) return null;
|
|
205
|
+
|
|
206
|
+
if (roleAttr.value.type === "Literal") {
|
|
207
|
+
return roleAttr.value.value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
roleAttr.value.type === "JSXExpressionContainer" &&
|
|
212
|
+
roleAttr.value.expression.type === "Literal"
|
|
213
|
+
) {
|
|
214
|
+
return roleAttr.value.expression.value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractStateVariableFromHandler(handlerAttr) {
|
|
221
|
+
if (!handlerAttr || !handlerAttr.value) return null;
|
|
222
|
+
|
|
223
|
+
if (handlerAttr.value.type !== "JSXExpressionContainer") return null;
|
|
224
|
+
|
|
225
|
+
const expression = handlerAttr.value.expression;
|
|
226
|
+
|
|
227
|
+
// Arrow function or function expression
|
|
228
|
+
if (
|
|
229
|
+
expression.type === "ArrowFunctionExpression" ||
|
|
230
|
+
expression.type === "FunctionExpression"
|
|
231
|
+
) {
|
|
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
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
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
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return stateVariable;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createStringConversionFix(fixer, node) {
|
|
275
|
+
const sourceCode = context.getSourceCode();
|
|
276
|
+
const expression = node.expression;
|
|
277
|
+
|
|
278
|
+
let identifierName;
|
|
279
|
+
|
|
280
|
+
if (expression.type === "Identifier") {
|
|
281
|
+
identifierName = expression.name;
|
|
282
|
+
} else if (
|
|
283
|
+
expression.type === "UnaryExpression" &&
|
|
284
|
+
expression.operator === "!"
|
|
285
|
+
) {
|
|
286
|
+
identifierName = sourceCode.getText(expression.argument);
|
|
287
|
+
return fixer.replaceText(
|
|
288
|
+
node,
|
|
289
|
+
`{${identifierName} ? 'false' : 'true'}`
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
const exprText = sourceCode.getText(expression);
|
|
293
|
+
return fixer.replaceText(node, `{${exprText} ? 'true' : 'false'}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return fixer.replaceText(node, `{${identifierName} ? 'true' : 'false'}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
JSXOpeningElement(node) {
|
|
301
|
+
const attributes = node.attributes || [];
|
|
302
|
+
const hasHandler = hasInteractiveHandler(attributes);
|
|
303
|
+
const role = getRoleValue(attributes);
|
|
304
|
+
|
|
305
|
+
attributes.forEach((attr) => {
|
|
306
|
+
if (attr.type !== "JSXAttribute") return;
|
|
307
|
+
|
|
308
|
+
const attrName = attr.name.name;
|
|
309
|
+
|
|
310
|
+
if (!DYNAMIC_ARIA_ATTRIBUTES[attrName]) return;
|
|
311
|
+
|
|
312
|
+
const attrValue = attr.value;
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
isBooleanIdentifier(attrValue) &&
|
|
316
|
+
!hasProperStringConversion(attrValue)
|
|
317
|
+
) {
|
|
318
|
+
context.report({
|
|
319
|
+
node: attr,
|
|
320
|
+
messageId: "booleanBinding",
|
|
321
|
+
data: {
|
|
322
|
+
ariaAttr: attrName,
|
|
323
|
+
},
|
|
324
|
+
fix(fixer) {
|
|
325
|
+
return createStringConversionFix(fixer, attrValue);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isStaticValue(attrValue) && hasHandler) {
|
|
332
|
+
const handlers = attributes.filter(
|
|
333
|
+
(a) =>
|
|
334
|
+
a.type === "JSXAttribute" &&
|
|
335
|
+
["onClick", "onKeyDown", "onChange"].includes(a.name.name)
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const handlerNames = handlers.map((a) => a.name.name);
|
|
339
|
+
|
|
340
|
+
// Try to extract state variable from handler for auto-fix
|
|
341
|
+
let stateVariable = null;
|
|
342
|
+
for (const handler of handlers) {
|
|
343
|
+
stateVariable = extractStateVariableFromHandler(handler);
|
|
344
|
+
if (stateVariable) break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
context.report({
|
|
348
|
+
node: attr,
|
|
349
|
+
messageId: "staticValueWithHandler",
|
|
350
|
+
data: {
|
|
351
|
+
ariaAttr: attrName,
|
|
352
|
+
handler: handlerNames.join(", "),
|
|
353
|
+
},
|
|
354
|
+
fix: stateVariable
|
|
355
|
+
? (fixer) => {
|
|
356
|
+
return fixer.replaceText(
|
|
357
|
+
attrValue,
|
|
358
|
+
`{${stateVariable} ? 'true' : 'false'}`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
: undefined,
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
role &&
|
|
368
|
+
INTERACTIVE_ROLES.includes(role) &&
|
|
369
|
+
isStaticValue(attrValue)
|
|
370
|
+
) {
|
|
371
|
+
context.report({
|
|
372
|
+
node: attr,
|
|
373
|
+
messageId: "missingStateBinding",
|
|
374
|
+
data: {
|
|
375
|
+
role: role,
|
|
376
|
+
ariaAttr: attrName,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
};
|