@wireweave/ux-rules 1.0.0 → 1.2.0-beta.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.md +192 -8
- package/dist/index.cjs +1355 -175
- package/dist/index.d.cts +158 -2
- package/dist/index.d.ts +158 -2
- package/dist/index.js +1308 -175
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,241 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var MAX_NAV_ITEMS = 7;
|
|
3
|
+
var MAX_TABS = 5;
|
|
4
|
+
var MAX_NESTING_DEPTH = 6;
|
|
5
|
+
var MAX_BUTTONS = 5;
|
|
6
|
+
var MAX_FORM_FIELDS = 10;
|
|
7
|
+
var MAX_PAGE_ELEMENTS = 50;
|
|
8
|
+
var MAX_BUTTON_TEXT_LENGTH = 25;
|
|
9
|
+
var MAX_TITLE_LENGTH = 60;
|
|
10
|
+
var MAX_TOOLTIP_LENGTH = 100;
|
|
11
|
+
var MAX_LIST_ITEMS = 20;
|
|
12
|
+
var MAX_TABLE_COLUMNS = 8;
|
|
13
|
+
var MIN_TOUCH_TARGET = 44;
|
|
14
|
+
var RECOMMENDED_TOUCH_TARGET = 48;
|
|
15
|
+
var SIZE_MAP = {
|
|
16
|
+
xs: 24,
|
|
17
|
+
sm: 32,
|
|
18
|
+
md: 40,
|
|
19
|
+
lg: 48,
|
|
20
|
+
xl: 56
|
|
21
|
+
};
|
|
22
|
+
var MIN_TOAST_DURATION = 2e3;
|
|
23
|
+
var MAX_TOAST_DURATION = 1e4;
|
|
24
|
+
var GENERIC_LINK_TEXTS = [
|
|
25
|
+
"click here",
|
|
26
|
+
"here",
|
|
27
|
+
"read more",
|
|
28
|
+
"more",
|
|
29
|
+
"link"
|
|
30
|
+
];
|
|
31
|
+
var ASYNC_ACTION_WORDS = [
|
|
32
|
+
"submit",
|
|
33
|
+
"save",
|
|
34
|
+
"send",
|
|
35
|
+
"upload",
|
|
36
|
+
"download",
|
|
37
|
+
"export",
|
|
38
|
+
"import",
|
|
39
|
+
"sync",
|
|
40
|
+
"load"
|
|
41
|
+
];
|
|
42
|
+
var DESTRUCTIVE_WORDS = [
|
|
43
|
+
"delete",
|
|
44
|
+
"remove",
|
|
45
|
+
"destroy",
|
|
46
|
+
"clear",
|
|
47
|
+
"reset",
|
|
48
|
+
"revoke",
|
|
49
|
+
"terminate"
|
|
50
|
+
];
|
|
51
|
+
var SUBMIT_WORDS = [
|
|
52
|
+
"submit",
|
|
53
|
+
"save",
|
|
54
|
+
"send",
|
|
55
|
+
"create",
|
|
56
|
+
"add",
|
|
57
|
+
"update",
|
|
58
|
+
"confirm",
|
|
59
|
+
"ok",
|
|
60
|
+
"done"
|
|
61
|
+
];
|
|
62
|
+
var ERROR_WORDS = [
|
|
63
|
+
"error",
|
|
64
|
+
"fail",
|
|
65
|
+
"invalid",
|
|
66
|
+
"wrong",
|
|
67
|
+
"denied"
|
|
68
|
+
];
|
|
69
|
+
var SUCCESS_WORDS = [
|
|
70
|
+
"success",
|
|
71
|
+
"saved",
|
|
72
|
+
"created",
|
|
73
|
+
"updated",
|
|
74
|
+
"complete"
|
|
75
|
+
];
|
|
76
|
+
var WARNING_WORDS = [
|
|
77
|
+
"warning",
|
|
78
|
+
"caution",
|
|
79
|
+
"attention",
|
|
80
|
+
"note"
|
|
81
|
+
];
|
|
82
|
+
var HOME_WORDS = [
|
|
83
|
+
"home",
|
|
84
|
+
"dashboard",
|
|
85
|
+
"main",
|
|
86
|
+
"start"
|
|
87
|
+
];
|
|
88
|
+
var CLOSE_WORDS = [
|
|
89
|
+
"close",
|
|
90
|
+
"cancel",
|
|
91
|
+
"dismiss",
|
|
92
|
+
"x"
|
|
93
|
+
];
|
|
94
|
+
var PLACEHOLDER_PATTERNS = [
|
|
95
|
+
"lorem ipsum",
|
|
96
|
+
"dolor sit amet",
|
|
97
|
+
"placeholder",
|
|
98
|
+
"sample text",
|
|
99
|
+
"text here",
|
|
100
|
+
"enter text",
|
|
101
|
+
"todo",
|
|
102
|
+
"tbd",
|
|
103
|
+
"xxx"
|
|
104
|
+
];
|
|
105
|
+
var INPUT_TYPE_SUGGESTIONS = [
|
|
106
|
+
{ keywords: ["email", "e-mail"], type: "email" },
|
|
107
|
+
{ keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
|
|
108
|
+
{ keywords: ["url", "website", "link"], type: "url" },
|
|
109
|
+
{ keywords: ["password", "pwd"], type: "password" },
|
|
110
|
+
{ keywords: ["search", "find", "query"], type: "search" },
|
|
111
|
+
{ keywords: ["date", "birthday", "dob"], type: "date" },
|
|
112
|
+
{ keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
|
|
113
|
+
];
|
|
114
|
+
var FORM_INPUT_TYPES = [
|
|
115
|
+
"Input",
|
|
116
|
+
"Textarea",
|
|
117
|
+
"Select",
|
|
118
|
+
"Checkbox",
|
|
119
|
+
"Radio"
|
|
120
|
+
];
|
|
121
|
+
var CONTAINER_TYPES = [
|
|
122
|
+
"Card",
|
|
123
|
+
"Section",
|
|
124
|
+
"Modal",
|
|
125
|
+
"Drawer",
|
|
126
|
+
"Main"
|
|
127
|
+
];
|
|
128
|
+
var TEXT_CONTENT_TYPES = [
|
|
129
|
+
"Text",
|
|
130
|
+
"Title",
|
|
131
|
+
"Label"
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// src/utils.ts
|
|
135
|
+
function getNodeText(node) {
|
|
136
|
+
if ("content" in node && node.content) return String(node.content);
|
|
137
|
+
if ("text" in node && node.text) return String(node.text);
|
|
138
|
+
if ("label" in node && node.label) return String(node.label);
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
function getNodeTextLower(node) {
|
|
142
|
+
return getNodeText(node).toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
function hasChildren(node) {
|
|
145
|
+
return "children" in node && Array.isArray(node.children);
|
|
146
|
+
}
|
|
147
|
+
function getChildren(node) {
|
|
148
|
+
if (hasChildren(node)) {
|
|
149
|
+
return node.children;
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
function findInChildren(node, predicate) {
|
|
154
|
+
const children = getChildren(node);
|
|
155
|
+
for (const child of children) {
|
|
156
|
+
if (predicate(child)) {
|
|
157
|
+
return child;
|
|
158
|
+
}
|
|
159
|
+
const found = findInChildren(child, predicate);
|
|
160
|
+
if (found) {
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function hasChildMatching(node, predicate) {
|
|
167
|
+
return findInChildren(node, predicate) !== null;
|
|
168
|
+
}
|
|
169
|
+
function countInChildren(node, predicate) {
|
|
170
|
+
const children = getChildren(node);
|
|
171
|
+
let count = 0;
|
|
172
|
+
for (const child of children) {
|
|
173
|
+
if (predicate(child)) {
|
|
174
|
+
count++;
|
|
175
|
+
}
|
|
176
|
+
count += countInChildren(child, predicate);
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
180
|
+
function filterChildren(node, predicate) {
|
|
181
|
+
const children = getChildren(node);
|
|
182
|
+
const result = [];
|
|
183
|
+
for (const child of children) {
|
|
184
|
+
if (predicate(child)) {
|
|
185
|
+
result.push(child);
|
|
186
|
+
}
|
|
187
|
+
result.push(...filterChildren(child, predicate));
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function getSizeValue(node) {
|
|
192
|
+
if (!("size" in node)) return null;
|
|
193
|
+
const size = node.size;
|
|
194
|
+
if (typeof size === "number") return size;
|
|
195
|
+
if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function containsAnyWord(text, words) {
|
|
199
|
+
const lowerText = text.toLowerCase();
|
|
200
|
+
return words.some((word) => lowerText.includes(word));
|
|
201
|
+
}
|
|
202
|
+
function matchesAnyWord(text, words) {
|
|
203
|
+
const lowerText = text.toLowerCase().trim();
|
|
204
|
+
return words.includes(lowerText);
|
|
205
|
+
}
|
|
206
|
+
function getNodeLocation(node) {
|
|
207
|
+
if ("loc" in node && node.loc && "start" in node.loc) {
|
|
208
|
+
return {
|
|
209
|
+
line: node.loc.start.line,
|
|
210
|
+
column: node.loc.start.column
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
function isNodeType(node, type) {
|
|
216
|
+
if (Array.isArray(type)) {
|
|
217
|
+
return type.includes(node.type);
|
|
218
|
+
}
|
|
219
|
+
return node.type === type;
|
|
220
|
+
}
|
|
221
|
+
function getButtonStyle(node) {
|
|
222
|
+
if ("primary" in node && node.primary) return "primary";
|
|
223
|
+
if ("secondary" in node && node.secondary) return "secondary";
|
|
224
|
+
if ("outline" in node && node.outline) return "outline";
|
|
225
|
+
if ("ghost" in node && node.ghost) return "ghost";
|
|
226
|
+
if ("danger" in node && node.danger) return "danger";
|
|
227
|
+
return "default";
|
|
228
|
+
}
|
|
229
|
+
function isAnyNode(value) {
|
|
230
|
+
return value !== null && typeof value === "object" && "type" in value && typeof value.type === "string";
|
|
231
|
+
}
|
|
232
|
+
function toAnyNodeArray(values) {
|
|
233
|
+
return values.filter(isAnyNode);
|
|
234
|
+
}
|
|
235
|
+
function getNodeItems(node) {
|
|
236
|
+
return "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
237
|
+
}
|
|
238
|
+
|
|
1
239
|
// src/rules/accessibility.ts
|
|
2
240
|
var inputRequiresLabel = {
|
|
3
241
|
id: "a11y-input-label",
|
|
@@ -19,7 +257,7 @@ var inputRequiresLabel = {
|
|
|
19
257
|
suggestion: hasPlaceholder ? "Add a label attribute. Placeholder alone is not sufficient for accessibility" : "Add a label attribute to describe this field",
|
|
20
258
|
path: context.path,
|
|
21
259
|
nodeType: node.type,
|
|
22
|
-
location: node
|
|
260
|
+
location: getNodeLocation(node)
|
|
23
261
|
};
|
|
24
262
|
}
|
|
25
263
|
return null;
|
|
@@ -44,7 +282,7 @@ var imageRequiresAlt = {
|
|
|
44
282
|
suggestion: "Add an alt attribute describing the image content",
|
|
45
283
|
path: context.path,
|
|
46
284
|
nodeType: node.type,
|
|
47
|
-
location: node
|
|
285
|
+
location: getNodeLocation(node)
|
|
48
286
|
};
|
|
49
287
|
}
|
|
50
288
|
return null;
|
|
@@ -59,7 +297,7 @@ var iconButtonRequiresLabel = {
|
|
|
59
297
|
appliesTo: ["Button"],
|
|
60
298
|
check: (node, context) => {
|
|
61
299
|
const hasIcon = "icon" in node && node.icon;
|
|
62
|
-
const hasContent =
|
|
300
|
+
const hasContent = getNodeText(node).trim();
|
|
63
301
|
if (hasIcon && !hasContent) {
|
|
64
302
|
return {
|
|
65
303
|
ruleId: "a11y-icon-button-label",
|
|
@@ -70,7 +308,7 @@ var iconButtonRequiresLabel = {
|
|
|
70
308
|
suggestion: "Add text content or aria-label to describe the button action",
|
|
71
309
|
path: context.path,
|
|
72
310
|
nodeType: node.type,
|
|
73
|
-
location: node
|
|
311
|
+
location: getNodeLocation(node)
|
|
74
312
|
};
|
|
75
313
|
}
|
|
76
314
|
return null;
|
|
@@ -84,9 +322,8 @@ var linkRequiresDescriptiveText = {
|
|
|
84
322
|
description: "Links should have descriptive text that indicates where they lead",
|
|
85
323
|
appliesTo: ["Link"],
|
|
86
324
|
check: (node, context) => {
|
|
87
|
-
const content =
|
|
88
|
-
|
|
89
|
-
if (genericTexts.includes(content.trim())) {
|
|
325
|
+
const content = getNodeText(node).toLowerCase();
|
|
326
|
+
if (GENERIC_LINK_TEXTS.includes(content.trim())) {
|
|
90
327
|
return {
|
|
91
328
|
ruleId: "a11y-link-text",
|
|
92
329
|
category: "accessibility",
|
|
@@ -96,7 +333,7 @@ var linkRequiresDescriptiveText = {
|
|
|
96
333
|
suggestion: `Replace "${content}" with descriptive text that indicates the link destination`,
|
|
97
334
|
path: context.path,
|
|
98
335
|
nodeType: node.type,
|
|
99
|
-
location: node
|
|
336
|
+
location: getNodeLocation(node)
|
|
100
337
|
};
|
|
101
338
|
}
|
|
102
339
|
return null;
|
|
@@ -128,7 +365,7 @@ var headingHierarchy = {
|
|
|
128
365
|
suggestion: `Use h${prevHeadingLevel + 1} instead of h${level}, or add intermediate headings`,
|
|
129
366
|
path: context.path,
|
|
130
367
|
nodeType: node.type,
|
|
131
|
-
location: node
|
|
368
|
+
location: getNodeLocation(node)
|
|
132
369
|
};
|
|
133
370
|
}
|
|
134
371
|
return null;
|
|
@@ -151,30 +388,16 @@ var formRequiresSubmit = {
|
|
|
151
388
|
description: "Forms with input fields should have a clear submit action",
|
|
152
389
|
appliesTo: ["Card", "Section", "Modal", "Main"],
|
|
153
390
|
check: (node, context) => {
|
|
154
|
-
if (!(
|
|
391
|
+
if (!hasChildren(node)) {
|
|
155
392
|
return null;
|
|
156
393
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (child.type === "Button") {
|
|
165
|
-
const isPrimary = "primary" in child && child.primary;
|
|
166
|
-
const content = "content" in child ? String(child.content || "").toLowerCase() : "";
|
|
167
|
-
const submitWords = ["submit", "save", "send", "create", "add", "update", "confirm", "ok", "done"];
|
|
168
|
-
if (isPrimary || submitWords.some((w) => content.includes(w))) {
|
|
169
|
-
hasSubmitButton = true;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if ("children" in child && Array.isArray(child.children)) {
|
|
173
|
-
walkChildren(child.children);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
walkChildren(node.children);
|
|
394
|
+
const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
|
|
395
|
+
const hasSubmitButton = hasChildMatching(node, (child) => {
|
|
396
|
+
if (child.type !== "Button") return false;
|
|
397
|
+
const isPrimary = "primary" in child && child.primary;
|
|
398
|
+
const content = getNodeText(child).toLowerCase();
|
|
399
|
+
return isPrimary || SUBMIT_WORDS.some((w) => content.includes(w));
|
|
400
|
+
});
|
|
178
401
|
if (hasInputs && !hasSubmitButton) {
|
|
179
402
|
return {
|
|
180
403
|
ruleId: "form-submit-button",
|
|
@@ -185,7 +408,7 @@ var formRequiresSubmit = {
|
|
|
185
408
|
suggestion: 'Add a primary button with a clear action label (e.g., "Submit", "Save")',
|
|
186
409
|
path: context.path,
|
|
187
410
|
nodeType: node.type,
|
|
188
|
-
location: node
|
|
411
|
+
location: getNodeLocation(node)
|
|
189
412
|
};
|
|
190
413
|
}
|
|
191
414
|
return null;
|
|
@@ -211,7 +434,7 @@ var requiredFieldIndicator = {
|
|
|
211
434
|
suggestion: 'Add an asterisk (*) to the label or include "required" in the label text',
|
|
212
435
|
path: context.path,
|
|
213
436
|
nodeType: node.type,
|
|
214
|
-
location: node
|
|
437
|
+
location: getNodeLocation(node)
|
|
215
438
|
};
|
|
216
439
|
}
|
|
217
440
|
return null;
|
|
@@ -252,7 +475,7 @@ var passwordConfirmation = {
|
|
|
252
475
|
suggestion: 'Add a "Confirm Password" field to prevent typos',
|
|
253
476
|
path: context.path,
|
|
254
477
|
nodeType: node.type,
|
|
255
|
-
location: node
|
|
478
|
+
location: getNodeLocation(node)
|
|
256
479
|
};
|
|
257
480
|
}
|
|
258
481
|
return null;
|
|
@@ -270,16 +493,7 @@ var appropriateInputType = {
|
|
|
270
493
|
const label = "label" in node ? String(node.label || "").toLowerCase() : "";
|
|
271
494
|
const placeholder = "placeholder" in node ? String(node.placeholder || "").toLowerCase() : "";
|
|
272
495
|
const combined = label + " " + placeholder;
|
|
273
|
-
const
|
|
274
|
-
{ keywords: ["email", "e-mail"], type: "email" },
|
|
275
|
-
{ keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
|
|
276
|
-
{ keywords: ["url", "website", "link"], type: "url" },
|
|
277
|
-
{ keywords: ["password", "pwd"], type: "password" },
|
|
278
|
-
{ keywords: ["search", "find", "query"], type: "search" },
|
|
279
|
-
{ keywords: ["date", "birthday", "dob"], type: "date" },
|
|
280
|
-
{ keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
|
|
281
|
-
];
|
|
282
|
-
for (const suggestion of suggestions) {
|
|
496
|
+
for (const suggestion of INPUT_TYPE_SUGGESTIONS) {
|
|
283
497
|
if (suggestion.keywords.some((k) => combined.includes(k)) && inputType !== suggestion.type) {
|
|
284
498
|
return {
|
|
285
499
|
ruleId: "form-input-type",
|
|
@@ -290,7 +504,7 @@ var appropriateInputType = {
|
|
|
290
504
|
suggestion: `Change inputType to "${suggestion.type}"`,
|
|
291
505
|
path: context.path,
|
|
292
506
|
nodeType: node.type,
|
|
293
|
-
location: node
|
|
507
|
+
location: getNodeLocation(node)
|
|
294
508
|
};
|
|
295
509
|
}
|
|
296
510
|
}
|
|
@@ -305,21 +519,6 @@ var formRules = [
|
|
|
305
519
|
];
|
|
306
520
|
|
|
307
521
|
// src/rules/touch-target.ts
|
|
308
|
-
var MIN_TOUCH_TARGET = 44;
|
|
309
|
-
var SIZE_MAP = {
|
|
310
|
-
xs: 24,
|
|
311
|
-
sm: 32,
|
|
312
|
-
md: 40,
|
|
313
|
-
lg: 48,
|
|
314
|
-
xl: 56
|
|
315
|
-
};
|
|
316
|
-
function getSizeValue(node) {
|
|
317
|
-
if (!("size" in node)) return null;
|
|
318
|
-
const size = node.size;
|
|
319
|
-
if (typeof size === "number") return size;
|
|
320
|
-
if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
522
|
var buttonTouchTarget = {
|
|
324
523
|
id: "touch-button-size",
|
|
325
524
|
category: "touch-target",
|
|
@@ -339,7 +538,7 @@ var buttonTouchTarget = {
|
|
|
339
538
|
suggestion: `Use size="md" or larger for better touch accessibility`,
|
|
340
539
|
path: context.path,
|
|
341
540
|
nodeType: node.type,
|
|
342
|
-
location: node
|
|
541
|
+
location: getNodeLocation(node)
|
|
343
542
|
};
|
|
344
543
|
}
|
|
345
544
|
return null;
|
|
@@ -368,7 +567,7 @@ var iconButtonTouchTarget = {
|
|
|
368
567
|
suggestion: "Add padding (p=2 or more) or use a larger size",
|
|
369
568
|
path: context.path,
|
|
370
569
|
nodeType: node.type,
|
|
371
|
-
location: node
|
|
570
|
+
location: getNodeLocation(node)
|
|
372
571
|
};
|
|
373
572
|
}
|
|
374
573
|
return null;
|
|
@@ -393,7 +592,7 @@ var checkboxRadioTouchTarget = {
|
|
|
393
592
|
suggestion: "Add a label to increase the touch target area",
|
|
394
593
|
path: context.path,
|
|
395
594
|
nodeType: node.type,
|
|
396
|
-
location: node
|
|
595
|
+
location: getNodeLocation(node)
|
|
397
596
|
};
|
|
398
597
|
}
|
|
399
598
|
return null;
|
|
@@ -422,7 +621,7 @@ var linkSpacing = {
|
|
|
422
621
|
suggestion: "Add gap=2 or more to the parent row for better touch separation",
|
|
423
622
|
path: context.path,
|
|
424
623
|
nodeType: node.type,
|
|
425
|
-
location: node
|
|
624
|
+
location: getNodeLocation(node)
|
|
426
625
|
};
|
|
427
626
|
}
|
|
428
627
|
}
|
|
@@ -449,7 +648,7 @@ var avatarTouchTarget = {
|
|
|
449
648
|
suggestion: 'Use size="md" or larger for clickable avatars',
|
|
450
649
|
path: context.path,
|
|
451
650
|
nodeType: node.type,
|
|
452
|
-
location: node
|
|
651
|
+
location: getNodeLocation(node)
|
|
453
652
|
};
|
|
454
653
|
}
|
|
455
654
|
return null;
|
|
@@ -464,18 +663,6 @@ var touchTargetRules = [
|
|
|
464
663
|
];
|
|
465
664
|
|
|
466
665
|
// src/rules/consistency.ts
|
|
467
|
-
var buttonStyleTracker = /* @__PURE__ */ new Map();
|
|
468
|
-
function resetConsistencyTrackers() {
|
|
469
|
-
buttonStyleTracker.clear();
|
|
470
|
-
}
|
|
471
|
-
function getButtonStyle(node) {
|
|
472
|
-
if ("primary" in node && node.primary) return "primary";
|
|
473
|
-
if ("secondary" in node && node.secondary) return "secondary";
|
|
474
|
-
if ("outline" in node && node.outline) return "outline";
|
|
475
|
-
if ("ghost" in node && node.ghost) return "ghost";
|
|
476
|
-
if ("danger" in node && node.danger) return "danger";
|
|
477
|
-
return "default";
|
|
478
|
-
}
|
|
479
666
|
var consistentButtonStyles = {
|
|
480
667
|
id: "consistency-button-styles",
|
|
481
668
|
category: "consistency",
|
|
@@ -484,10 +671,10 @@ var consistentButtonStyles = {
|
|
|
484
671
|
description: "Action buttons in the same context should use consistent styling",
|
|
485
672
|
appliesTo: ["Row", "Col", "Card", "Modal"],
|
|
486
673
|
check: (node, context) => {
|
|
487
|
-
if (!(
|
|
674
|
+
if (!hasChildren(node)) {
|
|
488
675
|
return null;
|
|
489
676
|
}
|
|
490
|
-
const buttons = node.
|
|
677
|
+
const buttons = getChildren(node).filter((c) => c.type === "Button");
|
|
491
678
|
if (buttons.length < 2) return null;
|
|
492
679
|
const styles = buttons.map((b) => getButtonStyle(b));
|
|
493
680
|
const uniqueStyles = [...new Set(styles)];
|
|
@@ -503,7 +690,7 @@ var consistentButtonStyles = {
|
|
|
503
690
|
suggestion: "Use primary for main action, outline/ghost for secondary actions",
|
|
504
691
|
path: context.path,
|
|
505
692
|
nodeType: node.type,
|
|
506
|
-
location: node
|
|
693
|
+
location: getNodeLocation(node)
|
|
507
694
|
};
|
|
508
695
|
}
|
|
509
696
|
return null;
|
|
@@ -531,7 +718,7 @@ var consistentSpacing = {
|
|
|
531
718
|
suggestion: "Use the same gap value for sibling containers",
|
|
532
719
|
path: context.path,
|
|
533
720
|
nodeType: node.type,
|
|
534
|
-
location: node
|
|
721
|
+
location: getNodeLocation(node)
|
|
535
722
|
};
|
|
536
723
|
}
|
|
537
724
|
return null;
|
|
@@ -571,7 +758,7 @@ var consistentCardStyling = {
|
|
|
571
758
|
suggestion: "Apply the same visual treatment to sibling cards",
|
|
572
759
|
path: context.path,
|
|
573
760
|
nodeType: node.type,
|
|
574
|
-
location: node
|
|
761
|
+
location: getNodeLocation(node)
|
|
575
762
|
};
|
|
576
763
|
}
|
|
577
764
|
return null;
|
|
@@ -585,12 +772,9 @@ var consistentAlertVariants = {
|
|
|
585
772
|
description: "Alerts should use appropriate variants for their purpose",
|
|
586
773
|
appliesTo: ["Alert"],
|
|
587
774
|
check: (node, context) => {
|
|
588
|
-
const content =
|
|
775
|
+
const content = getNodeText(node).toLowerCase();
|
|
589
776
|
const variant = "variant" in node ? String(node.variant || "") : "";
|
|
590
|
-
|
|
591
|
-
const successWords = ["success", "saved", "created", "updated", "complete"];
|
|
592
|
-
const warningWords = ["warning", "caution", "attention", "note"];
|
|
593
|
-
if (errorWords.some((w) => content.includes(w)) && variant !== "danger") {
|
|
777
|
+
if (ERROR_WORDS.some((w) => content.includes(w)) && variant !== "danger") {
|
|
594
778
|
return {
|
|
595
779
|
ruleId: "consistency-alert-variants",
|
|
596
780
|
category: "consistency",
|
|
@@ -600,10 +784,10 @@ var consistentAlertVariants = {
|
|
|
600
784
|
suggestion: "Add variant=danger to this error alert",
|
|
601
785
|
path: context.path,
|
|
602
786
|
nodeType: node.type,
|
|
603
|
-
location: node
|
|
787
|
+
location: getNodeLocation(node)
|
|
604
788
|
};
|
|
605
789
|
}
|
|
606
|
-
if (
|
|
790
|
+
if (SUCCESS_WORDS.some((w) => content.includes(w)) && variant !== "success") {
|
|
607
791
|
return {
|
|
608
792
|
ruleId: "consistency-alert-variants",
|
|
609
793
|
category: "consistency",
|
|
@@ -613,10 +797,10 @@ var consistentAlertVariants = {
|
|
|
613
797
|
suggestion: "Add variant=success to this success alert",
|
|
614
798
|
path: context.path,
|
|
615
799
|
nodeType: node.type,
|
|
616
|
-
location: node
|
|
800
|
+
location: getNodeLocation(node)
|
|
617
801
|
};
|
|
618
802
|
}
|
|
619
|
-
if (
|
|
803
|
+
if (WARNING_WORDS.some((w) => content.includes(w)) && variant !== "warning") {
|
|
620
804
|
return {
|
|
621
805
|
ruleId: "consistency-alert-variants",
|
|
622
806
|
category: "consistency",
|
|
@@ -626,7 +810,7 @@ var consistentAlertVariants = {
|
|
|
626
810
|
suggestion: "Add variant=warning to this warning alert",
|
|
627
811
|
path: context.path,
|
|
628
812
|
nodeType: node.type,
|
|
629
|
-
location: node
|
|
813
|
+
location: getNodeLocation(node)
|
|
630
814
|
};
|
|
631
815
|
}
|
|
632
816
|
return null;
|
|
@@ -658,7 +842,7 @@ var noEmptyContainers = {
|
|
|
658
842
|
suggestion: "Add content to this container or use a placeholder to indicate intended content",
|
|
659
843
|
path: context.path,
|
|
660
844
|
nodeType: node.type,
|
|
661
|
-
location: node
|
|
845
|
+
location: getNodeLocation(node)
|
|
662
846
|
};
|
|
663
847
|
}
|
|
664
848
|
return null;
|
|
@@ -672,22 +856,13 @@ var clearCTA = {
|
|
|
672
856
|
description: "Pages should have a clear primary action for users",
|
|
673
857
|
appliesTo: ["Page"],
|
|
674
858
|
check: (node, context) => {
|
|
675
|
-
if (!(
|
|
859
|
+
if (!hasChildren(node)) {
|
|
676
860
|
return null;
|
|
677
861
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
hasPrimaryButton = true;
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
if ("children" in child && Array.isArray(child.children)) {
|
|
686
|
-
findPrimaryButton(child.children);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
findPrimaryButton(node.children);
|
|
862
|
+
const hasPrimaryButton = hasChildMatching(
|
|
863
|
+
node,
|
|
864
|
+
(child) => child.type === "Button" && "primary" in child && !!child.primary
|
|
865
|
+
);
|
|
691
866
|
if (!hasPrimaryButton) {
|
|
692
867
|
return {
|
|
693
868
|
ruleId: "usability-clear-cta",
|
|
@@ -698,7 +873,7 @@ var clearCTA = {
|
|
|
698
873
|
suggestion: "Add a primary button for the main action on this page",
|
|
699
874
|
path: context.path,
|
|
700
875
|
nodeType: node.type,
|
|
701
|
-
location: node
|
|
876
|
+
location: getNodeLocation(node)
|
|
702
877
|
};
|
|
703
878
|
}
|
|
704
879
|
return null;
|
|
@@ -712,11 +887,10 @@ var loadingStates = {
|
|
|
712
887
|
description: "Actions that may take time should have loading indicators",
|
|
713
888
|
appliesTo: ["Button"],
|
|
714
889
|
check: (node, context) => {
|
|
715
|
-
const content =
|
|
890
|
+
const content = getNodeText(node).toLowerCase();
|
|
716
891
|
const hasLoading = "loading" in node;
|
|
717
892
|
const isPrimary = "primary" in node && node.primary;
|
|
718
|
-
|
|
719
|
-
if (isPrimary && asyncActions.some((a) => content.includes(a)) && !hasLoading) {
|
|
893
|
+
if (isPrimary && ASYNC_ACTION_WORDS.some((a) => content.includes(a)) && !hasLoading) {
|
|
720
894
|
return {
|
|
721
895
|
ruleId: "usability-loading-states",
|
|
722
896
|
category: "usability",
|
|
@@ -726,7 +900,7 @@ var loadingStates = {
|
|
|
726
900
|
suggestion: "Consider adding a loading variant for this button when action is in progress",
|
|
727
901
|
path: context.path,
|
|
728
902
|
nodeType: node.type,
|
|
729
|
-
location: node
|
|
903
|
+
location: getNodeLocation(node)
|
|
730
904
|
};
|
|
731
905
|
}
|
|
732
906
|
return null;
|
|
@@ -740,10 +914,9 @@ var destructiveActionConfirmation = {
|
|
|
740
914
|
description: "Destructive actions should have clear warning styling",
|
|
741
915
|
appliesTo: ["Button"],
|
|
742
916
|
check: (node, context) => {
|
|
743
|
-
const content =
|
|
917
|
+
const content = getNodeText(node).toLowerCase();
|
|
744
918
|
const isDanger = "danger" in node && node.danger;
|
|
745
|
-
|
|
746
|
-
if (destructiveWords.some((w) => content.includes(w)) && !isDanger) {
|
|
919
|
+
if (DESTRUCTIVE_WORDS.some((w) => content.includes(w)) && !isDanger) {
|
|
747
920
|
return {
|
|
748
921
|
ruleId: "usability-destructive-confirm",
|
|
749
922
|
category: "usability",
|
|
@@ -753,7 +926,7 @@ var destructiveActionConfirmation = {
|
|
|
753
926
|
suggestion: "Add the danger attribute to this button",
|
|
754
927
|
path: context.path,
|
|
755
928
|
nodeType: node.type,
|
|
756
|
-
location: node
|
|
929
|
+
location: getNodeLocation(node)
|
|
757
930
|
};
|
|
758
931
|
}
|
|
759
932
|
return null;
|
|
@@ -767,33 +940,21 @@ var modalCloseButton = {
|
|
|
767
940
|
description: "Users should be able to close modals easily",
|
|
768
941
|
appliesTo: ["Modal"],
|
|
769
942
|
check: (node, context) => {
|
|
770
|
-
if (!(
|
|
943
|
+
if (!hasChildren(node)) {
|
|
771
944
|
return null;
|
|
772
945
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
|
|
779
|
-
if (["close", "cancel", "dismiss", "x"].some((w) => content.includes(w) || icon.includes(w))) {
|
|
780
|
-
hasCloseButton = true;
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
if (child.type === "Icon") {
|
|
785
|
-
const name = "name" in child ? String(child.name || "").toLowerCase() : "";
|
|
786
|
-
if (name === "x" || name === "close") {
|
|
787
|
-
hasCloseButton = true;
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
if ("children" in child && Array.isArray(child.children)) {
|
|
792
|
-
findCloseButton(child.children);
|
|
793
|
-
}
|
|
946
|
+
const hasCloseButton = hasChildMatching(node, (child) => {
|
|
947
|
+
if (child.type === "Button") {
|
|
948
|
+
const content = getNodeText(child).toLowerCase();
|
|
949
|
+
const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
|
|
950
|
+
return CLOSE_WORDS.some((w) => content.includes(w) || icon.includes(w));
|
|
794
951
|
}
|
|
795
|
-
|
|
796
|
-
|
|
952
|
+
if (child.type === "Icon") {
|
|
953
|
+
const name = "name" in child ? String(child.name || "").toLowerCase() : "";
|
|
954
|
+
return name === "x" || name === "close";
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
});
|
|
797
958
|
if (!hasCloseButton) {
|
|
798
959
|
return {
|
|
799
960
|
ruleId: "usability-modal-close",
|
|
@@ -804,7 +965,7 @@ var modalCloseButton = {
|
|
|
804
965
|
suggestion: 'Add a close button (icon "x") or a "Cancel" button',
|
|
805
966
|
path: context.path,
|
|
806
967
|
nodeType: node.type,
|
|
807
|
-
location: node
|
|
968
|
+
location: getNodeLocation(node)
|
|
808
969
|
};
|
|
809
970
|
}
|
|
810
971
|
return null;
|
|
@@ -818,18 +979,126 @@ var maxNestingDepth = {
|
|
|
818
979
|
description: "Deeply nested layouts can be confusing and hard to maintain",
|
|
819
980
|
appliesTo: ["Row", "Col", "Card", "Section"],
|
|
820
981
|
check: (node, context) => {
|
|
821
|
-
|
|
822
|
-
if (context.depth > MAX_DEPTH) {
|
|
982
|
+
if (context.depth > MAX_NESTING_DEPTH) {
|
|
823
983
|
return {
|
|
824
984
|
ruleId: "usability-nesting-depth",
|
|
825
985
|
category: "usability",
|
|
826
986
|
severity: "warning",
|
|
827
|
-
message: `Component is nested ${context.depth} levels deep (max recommended: ${
|
|
987
|
+
message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_NESTING_DEPTH})`,
|
|
828
988
|
description: "Excessive nesting makes layouts harder to understand and maintain",
|
|
829
989
|
suggestion: "Consider flattening the layout or breaking into separate sections",
|
|
830
990
|
path: context.path,
|
|
831
991
|
nodeType: node.type,
|
|
832
|
-
location: node
|
|
992
|
+
location: getNodeLocation(node)
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
var tooManyButtons = {
|
|
999
|
+
id: "usability-too-many-buttons",
|
|
1000
|
+
category: "usability",
|
|
1001
|
+
severity: "warning",
|
|
1002
|
+
name: "Too many buttons in container",
|
|
1003
|
+
description: "Too many buttons can cause decision fatigue for users",
|
|
1004
|
+
appliesTo: ["Card", "Section", "Row", "Modal"],
|
|
1005
|
+
check: (node, context) => {
|
|
1006
|
+
if (!hasChildren(node)) {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
const buttonCount = getChildren(node).filter((c) => c.type === "Button").length;
|
|
1010
|
+
if (buttonCount > MAX_BUTTONS) {
|
|
1011
|
+
return {
|
|
1012
|
+
ruleId: "usability-too-many-buttons",
|
|
1013
|
+
category: "usability",
|
|
1014
|
+
severity: "warning",
|
|
1015
|
+
message: `Container has ${buttonCount} buttons (recommended max: ${MAX_BUTTONS})`,
|
|
1016
|
+
description: "Too many choices can overwhelm users and slow decision-making",
|
|
1017
|
+
suggestion: "Consider grouping actions in a dropdown or prioritizing the most important actions",
|
|
1018
|
+
path: context.path,
|
|
1019
|
+
nodeType: node.type,
|
|
1020
|
+
location: getNodeLocation(node)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
var tooManyFormFields = {
|
|
1027
|
+
id: "usability-too-many-form-fields",
|
|
1028
|
+
category: "usability",
|
|
1029
|
+
severity: "info",
|
|
1030
|
+
name: "Too many form fields",
|
|
1031
|
+
description: "Forms with many fields have higher abandonment rates",
|
|
1032
|
+
appliesTo: ["Card", "Section", "Main", "Modal"],
|
|
1033
|
+
check: (node, context) => {
|
|
1034
|
+
if (!hasChildren(node)) {
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
const formFieldCount = countInChildren(node, (child) => FORM_INPUT_TYPES.includes(child.type));
|
|
1038
|
+
if (formFieldCount > MAX_FORM_FIELDS) {
|
|
1039
|
+
return {
|
|
1040
|
+
ruleId: "usability-too-many-form-fields",
|
|
1041
|
+
category: "usability",
|
|
1042
|
+
severity: "info",
|
|
1043
|
+
message: `Form area has ${formFieldCount} fields (recommended max: ${MAX_FORM_FIELDS})`,
|
|
1044
|
+
description: "Long forms increase cognitive load and abandonment rates",
|
|
1045
|
+
suggestion: "Consider breaking into multiple steps, using progressive disclosure, or removing optional fields",
|
|
1046
|
+
path: context.path,
|
|
1047
|
+
nodeType: node.type,
|
|
1048
|
+
location: getNodeLocation(node)
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
var tooManyPageElements = {
|
|
1055
|
+
id: "usability-page-complexity",
|
|
1056
|
+
category: "usability",
|
|
1057
|
+
severity: "info",
|
|
1058
|
+
name: "Page may be too complex",
|
|
1059
|
+
description: "Pages with too many elements can overwhelm users",
|
|
1060
|
+
appliesTo: ["Page"],
|
|
1061
|
+
check: (node, context) => {
|
|
1062
|
+
if (!hasChildren(node)) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
const elementCount = countInChildren(node, () => true);
|
|
1066
|
+
if (elementCount > MAX_PAGE_ELEMENTS) {
|
|
1067
|
+
return {
|
|
1068
|
+
ruleId: "usability-page-complexity",
|
|
1069
|
+
category: "usability",
|
|
1070
|
+
severity: "info",
|
|
1071
|
+
message: `Page has ${elementCount} elements (consider if this complexity is necessary)`,
|
|
1072
|
+
description: "Complex pages can be overwhelming and slow to render",
|
|
1073
|
+
suggestion: "Consider splitting into multiple pages, using tabs, or simplifying the layout",
|
|
1074
|
+
path: context.path,
|
|
1075
|
+
nodeType: node.type,
|
|
1076
|
+
location: getNodeLocation(node)
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
var drawerWidth = {
|
|
1083
|
+
id: "usability-drawer-width",
|
|
1084
|
+
category: "usability",
|
|
1085
|
+
severity: "info",
|
|
1086
|
+
name: "Drawer should have appropriate width",
|
|
1087
|
+
description: "Drawers should have a defined width for consistent UX",
|
|
1088
|
+
appliesTo: ["Drawer"],
|
|
1089
|
+
check: (node, context) => {
|
|
1090
|
+
const hasWidth = "width" in node || "w" in node;
|
|
1091
|
+
if (!hasWidth) {
|
|
1092
|
+
return {
|
|
1093
|
+
ruleId: "usability-drawer-width",
|
|
1094
|
+
category: "usability",
|
|
1095
|
+
severity: "info",
|
|
1096
|
+
message: "Drawer has no width specified",
|
|
1097
|
+
description: "Drawers without explicit width may render inconsistently across devices",
|
|
1098
|
+
suggestion: 'Add a width attribute (e.g., width="320" or w="80")',
|
|
1099
|
+
path: context.path,
|
|
1100
|
+
nodeType: node.type,
|
|
1101
|
+
location: getNodeLocation(node)
|
|
833
1102
|
};
|
|
834
1103
|
}
|
|
835
1104
|
return null;
|
|
@@ -841,7 +1110,11 @@ var usabilityRules = [
|
|
|
841
1110
|
loadingStates,
|
|
842
1111
|
destructiveActionConfirmation,
|
|
843
1112
|
modalCloseButton,
|
|
844
|
-
maxNestingDepth
|
|
1113
|
+
maxNestingDepth,
|
|
1114
|
+
tooManyButtons,
|
|
1115
|
+
tooManyFormFields,
|
|
1116
|
+
tooManyPageElements,
|
|
1117
|
+
drawerWidth
|
|
845
1118
|
];
|
|
846
1119
|
|
|
847
1120
|
// src/rules/navigation.ts
|
|
@@ -853,8 +1126,7 @@ var navItemCount = {
|
|
|
853
1126
|
description: "Navigation menus with too many items can overwhelm users",
|
|
854
1127
|
appliesTo: ["Nav"],
|
|
855
1128
|
check: (node, context) => {
|
|
856
|
-
const
|
|
857
|
-
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
1129
|
+
const items = getNodeItems(node);
|
|
858
1130
|
if (items.length > MAX_NAV_ITEMS) {
|
|
859
1131
|
return {
|
|
860
1132
|
ruleId: "nav-item-count",
|
|
@@ -865,7 +1137,7 @@ var navItemCount = {
|
|
|
865
1137
|
suggestion: "Group related items into categories or use a hierarchical navigation",
|
|
866
1138
|
path: context.path,
|
|
867
1139
|
nodeType: node.type,
|
|
868
|
-
location: node
|
|
1140
|
+
location: getNodeLocation(node)
|
|
869
1141
|
};
|
|
870
1142
|
}
|
|
871
1143
|
return null;
|
|
@@ -879,7 +1151,7 @@ var navActiveState = {
|
|
|
879
1151
|
description: "Users should know which page they are currently on",
|
|
880
1152
|
appliesTo: ["Nav"],
|
|
881
1153
|
check: (node, context) => {
|
|
882
|
-
const items =
|
|
1154
|
+
const items = getNodeItems(node);
|
|
883
1155
|
if (items.length === 0) return null;
|
|
884
1156
|
const hasActiveItem = items.some((item) => {
|
|
885
1157
|
if (typeof item === "object" && item !== null) {
|
|
@@ -897,7 +1169,7 @@ var navActiveState = {
|
|
|
897
1169
|
suggestion: "Add active attribute to the current navigation item",
|
|
898
1170
|
path: context.path,
|
|
899
1171
|
nodeType: node.type,
|
|
900
|
-
location: node
|
|
1172
|
+
location: getNodeLocation(node)
|
|
901
1173
|
};
|
|
902
1174
|
}
|
|
903
1175
|
return null;
|
|
@@ -911,12 +1183,11 @@ var breadcrumbHasHome = {
|
|
|
911
1183
|
description: "Breadcrumbs typically start with a home or root link",
|
|
912
1184
|
appliesTo: ["Breadcrumb"],
|
|
913
1185
|
check: (node, context) => {
|
|
914
|
-
const items =
|
|
1186
|
+
const items = getNodeItems(node);
|
|
915
1187
|
if (items.length === 0) return null;
|
|
916
1188
|
const firstItem = items[0];
|
|
917
1189
|
const firstLabel = typeof firstItem === "string" ? firstItem.toLowerCase() : firstItem?.label?.toLowerCase() || "";
|
|
918
|
-
|
|
919
|
-
if (!homeWords.some((w) => firstLabel.includes(w))) {
|
|
1190
|
+
if (!HOME_WORDS.some((w) => firstLabel.includes(w))) {
|
|
920
1191
|
return {
|
|
921
1192
|
ruleId: "nav-breadcrumb-home",
|
|
922
1193
|
category: "navigation",
|
|
@@ -926,7 +1197,7 @@ var breadcrumbHasHome = {
|
|
|
926
1197
|
suggestion: 'Add "Home" or equivalent as the first breadcrumb item',
|
|
927
1198
|
path: context.path,
|
|
928
1199
|
nodeType: node.type,
|
|
929
|
-
location: node
|
|
1200
|
+
location: getNodeLocation(node)
|
|
930
1201
|
};
|
|
931
1202
|
}
|
|
932
1203
|
return null;
|
|
@@ -940,15 +1211,9 @@ var tabCount = {
|
|
|
940
1211
|
description: "Too many tabs can be overwhelming",
|
|
941
1212
|
appliesTo: ["Tabs"],
|
|
942
1213
|
check: (node, context) => {
|
|
943
|
-
const
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
const childTabCount = children.filter((c) => {
|
|
947
|
-
if (typeof c === "object" && c !== null && "type" in c) {
|
|
948
|
-
return String(c.type).toLowerCase().includes("tab");
|
|
949
|
-
}
|
|
950
|
-
return false;
|
|
951
|
-
}).length;
|
|
1214
|
+
const items = getNodeItems(node);
|
|
1215
|
+
const children = hasChildren(node) ? getChildren(node) : [];
|
|
1216
|
+
const childTabCount = children.filter((c) => c.type.toLowerCase().includes("tab")).length;
|
|
952
1217
|
const tabCount2 = items.length || childTabCount;
|
|
953
1218
|
if (tabCount2 > MAX_TABS) {
|
|
954
1219
|
return {
|
|
@@ -960,7 +1225,7 @@ var tabCount = {
|
|
|
960
1225
|
suggestion: "Consider using a different navigation pattern or grouping related content",
|
|
961
1226
|
path: context.path,
|
|
962
1227
|
nodeType: node.type,
|
|
963
|
-
location: node
|
|
1228
|
+
location: getNodeLocation(node)
|
|
964
1229
|
};
|
|
965
1230
|
}
|
|
966
1231
|
return null;
|
|
@@ -974,8 +1239,8 @@ var dropdownHasItems = {
|
|
|
974
1239
|
description: "Dropdown menus need items to be functional",
|
|
975
1240
|
appliesTo: ["Dropdown"],
|
|
976
1241
|
check: (node, context) => {
|
|
977
|
-
const items =
|
|
978
|
-
const children =
|
|
1242
|
+
const items = getNodeItems(node);
|
|
1243
|
+
const children = hasChildren(node) ? getChildren(node) : [];
|
|
979
1244
|
if (items.length === 0 && children.length === 0) {
|
|
980
1245
|
return {
|
|
981
1246
|
ruleId: "nav-dropdown-items",
|
|
@@ -986,7 +1251,7 @@ var dropdownHasItems = {
|
|
|
986
1251
|
suggestion: "Add items to the dropdown menu",
|
|
987
1252
|
path: context.path,
|
|
988
1253
|
nodeType: node.type,
|
|
989
|
-
location: node
|
|
1254
|
+
location: getNodeLocation(node)
|
|
990
1255
|
};
|
|
991
1256
|
}
|
|
992
1257
|
return null;
|
|
@@ -1000,6 +1265,819 @@ var navigationRules = [
|
|
|
1000
1265
|
dropdownHasItems
|
|
1001
1266
|
];
|
|
1002
1267
|
|
|
1268
|
+
// src/rules/content.ts
|
|
1269
|
+
var emptyTextContent = {
|
|
1270
|
+
id: "content-empty-text",
|
|
1271
|
+
category: "content",
|
|
1272
|
+
severity: "warning",
|
|
1273
|
+
name: "Avoid empty text content",
|
|
1274
|
+
description: "Text elements should have meaningful content",
|
|
1275
|
+
appliesTo: ["Text", "Title", "Label"],
|
|
1276
|
+
check: (node, context) => {
|
|
1277
|
+
const trimmed = getNodeText(node).trim();
|
|
1278
|
+
if (trimmed === "" || trimmed === "..." || trimmed === "Lorem ipsum") {
|
|
1279
|
+
return {
|
|
1280
|
+
ruleId: "content-empty-text",
|
|
1281
|
+
category: "content",
|
|
1282
|
+
severity: "warning",
|
|
1283
|
+
message: `${node.type} has placeholder or empty content`,
|
|
1284
|
+
description: "Placeholder text should be replaced with meaningful content",
|
|
1285
|
+
suggestion: "Replace with actual content or remove if not needed",
|
|
1286
|
+
path: context.path,
|
|
1287
|
+
nodeType: node.type,
|
|
1288
|
+
location: getNodeLocation(node)
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
var buttonTextLength = {
|
|
1295
|
+
id: "content-button-text-length",
|
|
1296
|
+
category: "content",
|
|
1297
|
+
severity: "info",
|
|
1298
|
+
name: "Button text should be concise",
|
|
1299
|
+
description: "Button labels should be short and action-oriented",
|
|
1300
|
+
appliesTo: ["Button"],
|
|
1301
|
+
check: (node, context) => {
|
|
1302
|
+
const content = getNodeText(node);
|
|
1303
|
+
if (content.length > MAX_BUTTON_TEXT_LENGTH) {
|
|
1304
|
+
return {
|
|
1305
|
+
ruleId: "content-button-text-length",
|
|
1306
|
+
category: "content",
|
|
1307
|
+
severity: "info",
|
|
1308
|
+
message: `Button text is ${content.length} characters (recommended max: ${MAX_BUTTON_TEXT_LENGTH})`,
|
|
1309
|
+
description: "Long button text can be hard to read and may not fit on smaller screens",
|
|
1310
|
+
suggestion: 'Use concise, action-oriented text (e.g., "Save" instead of "Click here to save your changes")',
|
|
1311
|
+
path: context.path,
|
|
1312
|
+
nodeType: node.type,
|
|
1313
|
+
location: getNodeLocation(node)
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
var titleLength = {
|
|
1320
|
+
id: "content-title-length",
|
|
1321
|
+
category: "content",
|
|
1322
|
+
severity: "info",
|
|
1323
|
+
name: "Title should be concise",
|
|
1324
|
+
description: "Titles should be short and descriptive",
|
|
1325
|
+
appliesTo: ["Title"],
|
|
1326
|
+
check: (node, context) => {
|
|
1327
|
+
const content = getNodeText(node);
|
|
1328
|
+
if (content.length > MAX_TITLE_LENGTH) {
|
|
1329
|
+
return {
|
|
1330
|
+
ruleId: "content-title-length",
|
|
1331
|
+
category: "content",
|
|
1332
|
+
severity: "info",
|
|
1333
|
+
message: `Title is ${content.length} characters (recommended max: ${MAX_TITLE_LENGTH})`,
|
|
1334
|
+
description: "Long titles can be hard to scan and may get truncated on smaller screens",
|
|
1335
|
+
suggestion: "Shorten the title and move details to a subtitle or description",
|
|
1336
|
+
path: context.path,
|
|
1337
|
+
nodeType: node.type,
|
|
1338
|
+
location: getNodeLocation(node)
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
var pageHasTitle = {
|
|
1345
|
+
id: "content-page-title",
|
|
1346
|
+
category: "content",
|
|
1347
|
+
severity: "warning",
|
|
1348
|
+
name: "Page should have a title",
|
|
1349
|
+
description: "Every page should have a clear title to orient users",
|
|
1350
|
+
appliesTo: ["Page"],
|
|
1351
|
+
check: (node, context) => {
|
|
1352
|
+
if (!hasChildren(node)) {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
const hasTitleElement = hasChildMatching(node, (child) => child.type === "Title");
|
|
1356
|
+
if (!hasTitleElement) {
|
|
1357
|
+
return {
|
|
1358
|
+
ruleId: "content-page-title",
|
|
1359
|
+
category: "content",
|
|
1360
|
+
severity: "warning",
|
|
1361
|
+
message: "Page has no title",
|
|
1362
|
+
description: "Users need a clear title to understand the page purpose",
|
|
1363
|
+
suggestion: "Add a Title component to identify the page",
|
|
1364
|
+
path: context.path,
|
|
1365
|
+
nodeType: node.type,
|
|
1366
|
+
location: getNodeLocation(node)
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
var linkHasText = {
|
|
1373
|
+
id: "content-link-text",
|
|
1374
|
+
category: "content",
|
|
1375
|
+
severity: "error",
|
|
1376
|
+
name: "Link should have text",
|
|
1377
|
+
description: "Links must have visible text for users to understand where they lead",
|
|
1378
|
+
appliesTo: ["Link"],
|
|
1379
|
+
check: (node, context) => {
|
|
1380
|
+
const content = getNodeText(node).trim();
|
|
1381
|
+
const hasChildElements = hasChildren(node) && getChildren(node).length > 0;
|
|
1382
|
+
if (!content && !hasChildElements) {
|
|
1383
|
+
return {
|
|
1384
|
+
ruleId: "content-link-text",
|
|
1385
|
+
category: "content",
|
|
1386
|
+
severity: "error",
|
|
1387
|
+
message: "Link has no visible text or content",
|
|
1388
|
+
description: "Users cannot understand or interact with links that have no text",
|
|
1389
|
+
suggestion: "Add descriptive text to the link",
|
|
1390
|
+
path: context.path,
|
|
1391
|
+
nodeType: node.type,
|
|
1392
|
+
location: getNodeLocation(node)
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
var noPlaceholderContent = {
|
|
1399
|
+
id: "content-no-placeholder",
|
|
1400
|
+
category: "content",
|
|
1401
|
+
severity: "warning",
|
|
1402
|
+
name: "Avoid placeholder content",
|
|
1403
|
+
description: 'Placeholder text like "Lorem ipsum" should be replaced',
|
|
1404
|
+
appliesTo: ["Text", "Title", "Label", "Button"],
|
|
1405
|
+
check: (node, context) => {
|
|
1406
|
+
const content = getNodeText(node).toLowerCase();
|
|
1407
|
+
for (const placeholder of PLACEHOLDER_PATTERNS) {
|
|
1408
|
+
if (content.includes(placeholder)) {
|
|
1409
|
+
return {
|
|
1410
|
+
ruleId: "content-no-placeholder",
|
|
1411
|
+
category: "content",
|
|
1412
|
+
severity: "warning",
|
|
1413
|
+
message: `${node.type} contains placeholder text "${placeholder}"`,
|
|
1414
|
+
description: "Placeholder text should be replaced before production",
|
|
1415
|
+
suggestion: "Replace with actual content",
|
|
1416
|
+
path: context.path,
|
|
1417
|
+
nodeType: node.type,
|
|
1418
|
+
location: getNodeLocation(node)
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
var contentRules = [
|
|
1426
|
+
emptyTextContent,
|
|
1427
|
+
buttonTextLength,
|
|
1428
|
+
titleLength,
|
|
1429
|
+
pageHasTitle,
|
|
1430
|
+
linkHasText,
|
|
1431
|
+
noPlaceholderContent
|
|
1432
|
+
];
|
|
1433
|
+
|
|
1434
|
+
// src/rules/data-display.ts
|
|
1435
|
+
var tableHasHeader = {
|
|
1436
|
+
id: "data-table-header",
|
|
1437
|
+
category: "data-display",
|
|
1438
|
+
severity: "warning",
|
|
1439
|
+
name: "Table should have header",
|
|
1440
|
+
description: "Tables should have a header row to identify columns",
|
|
1441
|
+
appliesTo: ["Table"],
|
|
1442
|
+
check: (node, context) => {
|
|
1443
|
+
const hasHeader = "header" in node && node.header;
|
|
1444
|
+
const hasColumns = "columns" in node && Array.isArray(node.columns) && node.columns.length > 0;
|
|
1445
|
+
if (!hasHeader && !hasColumns) {
|
|
1446
|
+
return {
|
|
1447
|
+
ruleId: "data-table-header",
|
|
1448
|
+
category: "data-display",
|
|
1449
|
+
severity: "warning",
|
|
1450
|
+
message: "Table has no header or columns defined",
|
|
1451
|
+
description: "Table headers help users understand what each column represents",
|
|
1452
|
+
suggestion: "Add a header attribute or define columns for the table",
|
|
1453
|
+
path: context.path,
|
|
1454
|
+
nodeType: node.type,
|
|
1455
|
+
location: getNodeLocation(node)
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
var listEmptyState = {
|
|
1462
|
+
id: "data-list-empty-state",
|
|
1463
|
+
category: "data-display",
|
|
1464
|
+
severity: "info",
|
|
1465
|
+
name: "Consider empty state for list",
|
|
1466
|
+
description: "Lists should indicate when they have no items",
|
|
1467
|
+
appliesTo: ["List"],
|
|
1468
|
+
check: (node, context) => {
|
|
1469
|
+
const items = getNodeItems(node);
|
|
1470
|
+
const children = hasChildren(node) ? getChildren(node) : [];
|
|
1471
|
+
const hasEmptyStateAttr = "emptyState" in node || "empty" in node;
|
|
1472
|
+
if (items.length > 0 || children.length > 0) {
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
if (!hasEmptyStateAttr) {
|
|
1476
|
+
return {
|
|
1477
|
+
ruleId: "data-list-empty-state",
|
|
1478
|
+
category: "data-display",
|
|
1479
|
+
severity: "info",
|
|
1480
|
+
message: "Empty list has no empty state defined",
|
|
1481
|
+
description: "Users should see a helpful message when lists are empty",
|
|
1482
|
+
suggestion: "Add an emptyState attribute or component to show when list is empty",
|
|
1483
|
+
path: context.path,
|
|
1484
|
+
nodeType: node.type,
|
|
1485
|
+
location: getNodeLocation(node)
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
var tableEmptyState = {
|
|
1492
|
+
id: "data-table-empty-state",
|
|
1493
|
+
category: "data-display",
|
|
1494
|
+
severity: "info",
|
|
1495
|
+
name: "Consider empty state for table",
|
|
1496
|
+
description: "Tables should indicate when they have no data",
|
|
1497
|
+
appliesTo: ["Table"],
|
|
1498
|
+
check: (node, context) => {
|
|
1499
|
+
const rows = "rows" in node && Array.isArray(node.rows) ? node.rows : [];
|
|
1500
|
+
const data = "data" in node && Array.isArray(node.data) ? node.data : [];
|
|
1501
|
+
const hasEmptyState = "emptyState" in node || "empty" in node;
|
|
1502
|
+
if (rows.length > 0 || data.length > 0) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
if (!hasEmptyState) {
|
|
1506
|
+
return {
|
|
1507
|
+
ruleId: "data-table-empty-state",
|
|
1508
|
+
category: "data-display",
|
|
1509
|
+
severity: "info",
|
|
1510
|
+
message: "Empty table has no empty state defined",
|
|
1511
|
+
description: "Users should see a helpful message when tables have no data",
|
|
1512
|
+
suggestion: "Add an emptyState attribute to show when table is empty",
|
|
1513
|
+
path: context.path,
|
|
1514
|
+
nodeType: node.type,
|
|
1515
|
+
location: getNodeLocation(node)
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
var listTooManyItems = {
|
|
1522
|
+
id: "data-list-pagination",
|
|
1523
|
+
category: "data-display",
|
|
1524
|
+
severity: "info",
|
|
1525
|
+
name: "Long list may need pagination",
|
|
1526
|
+
description: "Lists with many items should consider pagination or virtualization",
|
|
1527
|
+
appliesTo: ["List"],
|
|
1528
|
+
check: (node, context) => {
|
|
1529
|
+
const items = getNodeItems(node);
|
|
1530
|
+
const children = hasChildren(node) ? getChildren(node) : [];
|
|
1531
|
+
const hasPagination = "pagination" in node || "paginated" in node;
|
|
1532
|
+
const itemCount = items.length || children.filter((c) => isNodeType(c, "ListItem")).length;
|
|
1533
|
+
if (itemCount > MAX_LIST_ITEMS && !hasPagination) {
|
|
1534
|
+
return {
|
|
1535
|
+
ruleId: "data-list-pagination",
|
|
1536
|
+
category: "data-display",
|
|
1537
|
+
severity: "info",
|
|
1538
|
+
message: `List has ${itemCount} items (consider pagination for better performance)`,
|
|
1539
|
+
description: "Long lists can be slow to render and overwhelming for users",
|
|
1540
|
+
suggestion: "Add pagination, infinite scroll, or virtualization for long lists",
|
|
1541
|
+
path: context.path,
|
|
1542
|
+
nodeType: node.type,
|
|
1543
|
+
location: getNodeLocation(node)
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
var tableTooManyColumns = {
|
|
1550
|
+
id: "data-table-columns",
|
|
1551
|
+
category: "data-display",
|
|
1552
|
+
severity: "warning",
|
|
1553
|
+
name: "Table has too many columns",
|
|
1554
|
+
description: "Tables with many columns are hard to read on smaller screens",
|
|
1555
|
+
appliesTo: ["Table"],
|
|
1556
|
+
check: (node, context) => {
|
|
1557
|
+
const columns = "columns" in node && Array.isArray(node.columns) ? node.columns : [];
|
|
1558
|
+
if (columns.length > MAX_TABLE_COLUMNS) {
|
|
1559
|
+
return {
|
|
1560
|
+
ruleId: "data-table-columns",
|
|
1561
|
+
category: "data-display",
|
|
1562
|
+
severity: "warning",
|
|
1563
|
+
message: `Table has ${columns.length} columns (recommended max: ${MAX_TABLE_COLUMNS})`,
|
|
1564
|
+
description: "Tables with many columns are difficult to read and may not fit on mobile",
|
|
1565
|
+
suggestion: "Consider hiding less important columns on mobile or using a different layout",
|
|
1566
|
+
path: context.path,
|
|
1567
|
+
nodeType: node.type,
|
|
1568
|
+
location: getNodeLocation(node)
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
var cardGridConsistency = {
|
|
1575
|
+
id: "data-card-grid",
|
|
1576
|
+
category: "data-display",
|
|
1577
|
+
severity: "info",
|
|
1578
|
+
name: "Card grid should have consistent items",
|
|
1579
|
+
description: "Cards in a grid layout should have consistent dimensions",
|
|
1580
|
+
appliesTo: ["Row"],
|
|
1581
|
+
check: (node, context) => {
|
|
1582
|
+
if (!hasChildren(node)) {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
const cards = getChildren(node).filter((c) => c.type === "Card");
|
|
1586
|
+
if (cards.length < 3) return null;
|
|
1587
|
+
const heights = cards.map((c) => "height" in c ? c.height : "h" in c ? c.h : null);
|
|
1588
|
+
const definedHeights = heights.filter((h) => h !== null);
|
|
1589
|
+
const uniqueHeights = [...new Set(definedHeights)];
|
|
1590
|
+
if (uniqueHeights.length > 1) {
|
|
1591
|
+
return {
|
|
1592
|
+
ruleId: "data-card-grid",
|
|
1593
|
+
category: "data-display",
|
|
1594
|
+
severity: "info",
|
|
1595
|
+
message: "Card grid has inconsistent card heights",
|
|
1596
|
+
description: "Inconsistent card sizes can create visual imbalance",
|
|
1597
|
+
suggestion: "Consider using consistent heights for cards in a grid layout",
|
|
1598
|
+
path: context.path,
|
|
1599
|
+
nodeType: node.type,
|
|
1600
|
+
location: getNodeLocation(node)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
var dataDisplayRules = [
|
|
1607
|
+
tableHasHeader,
|
|
1608
|
+
listEmptyState,
|
|
1609
|
+
tableEmptyState,
|
|
1610
|
+
listTooManyItems,
|
|
1611
|
+
tableTooManyColumns,
|
|
1612
|
+
cardGridConsistency
|
|
1613
|
+
];
|
|
1614
|
+
|
|
1615
|
+
// src/rules/feedback.ts
|
|
1616
|
+
var spinnerHasContext = {
|
|
1617
|
+
id: "feedback-spinner-context",
|
|
1618
|
+
category: "feedback",
|
|
1619
|
+
severity: "info",
|
|
1620
|
+
name: "Spinner should have context",
|
|
1621
|
+
description: "Loading spinners should indicate what is being loaded",
|
|
1622
|
+
appliesTo: ["Spinner"],
|
|
1623
|
+
check: (node, context) => {
|
|
1624
|
+
const hasText = "text" in node && node.text;
|
|
1625
|
+
const hasLabel = "label" in node && node.label;
|
|
1626
|
+
const hasContent = "content" in node && node.content;
|
|
1627
|
+
const siblingText = context.siblings.some(
|
|
1628
|
+
(s) => s.type === "Text" && "content" in s && String(s.content || "").toLowerCase().includes("loading")
|
|
1629
|
+
);
|
|
1630
|
+
if (!hasText && !hasLabel && !hasContent && !siblingText) {
|
|
1631
|
+
return {
|
|
1632
|
+
ruleId: "feedback-spinner-context",
|
|
1633
|
+
category: "feedback",
|
|
1634
|
+
severity: "info",
|
|
1635
|
+
message: "Spinner has no loading text",
|
|
1636
|
+
description: "Users benefit from knowing what is being loaded",
|
|
1637
|
+
suggestion: 'Add a text/label attribute like "Loading..." or "Please wait..."',
|
|
1638
|
+
path: context.path,
|
|
1639
|
+
nodeType: node.type,
|
|
1640
|
+
location: getNodeLocation(node)
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
var progressHasValue = {
|
|
1647
|
+
id: "feedback-progress-value",
|
|
1648
|
+
category: "feedback",
|
|
1649
|
+
severity: "info",
|
|
1650
|
+
name: "Progress should show value",
|
|
1651
|
+
description: "Progress bars should indicate completion percentage",
|
|
1652
|
+
appliesTo: ["Progress"],
|
|
1653
|
+
check: (node, context) => {
|
|
1654
|
+
const hasValue = "value" in node;
|
|
1655
|
+
const hasPercent = "percent" in node;
|
|
1656
|
+
const isIndeterminate = "indeterminate" in node && node.indeterminate;
|
|
1657
|
+
if (isIndeterminate) return null;
|
|
1658
|
+
if (!hasValue && !hasPercent) {
|
|
1659
|
+
return {
|
|
1660
|
+
ruleId: "feedback-progress-value",
|
|
1661
|
+
category: "feedback",
|
|
1662
|
+
severity: "info",
|
|
1663
|
+
message: "Progress bar has no value specified",
|
|
1664
|
+
description: "Users should see how much progress has been made",
|
|
1665
|
+
suggestion: "Add a value attribute (0-100) or use indeterminate for unknown duration",
|
|
1666
|
+
path: context.path,
|
|
1667
|
+
nodeType: node.type,
|
|
1668
|
+
location: getNodeLocation(node)
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
var toastDuration = {
|
|
1675
|
+
id: "feedback-toast-duration",
|
|
1676
|
+
category: "feedback",
|
|
1677
|
+
severity: "info",
|
|
1678
|
+
name: "Toast should have appropriate duration",
|
|
1679
|
+
description: "Toasts should auto-dismiss after a reasonable time",
|
|
1680
|
+
appliesTo: ["Toast"],
|
|
1681
|
+
check: (node, context) => {
|
|
1682
|
+
const duration = "duration" in node ? Number(node.duration) : null;
|
|
1683
|
+
if (duration !== null) {
|
|
1684
|
+
if (duration < MIN_TOAST_DURATION) {
|
|
1685
|
+
return {
|
|
1686
|
+
ruleId: "feedback-toast-duration",
|
|
1687
|
+
category: "feedback",
|
|
1688
|
+
severity: "info",
|
|
1689
|
+
message: `Toast duration (${duration}ms) may be too short to read`,
|
|
1690
|
+
description: "Users need time to read toast messages",
|
|
1691
|
+
suggestion: `Increase duration to at least ${MIN_TOAST_DURATION}ms`,
|
|
1692
|
+
path: context.path,
|
|
1693
|
+
nodeType: node.type,
|
|
1694
|
+
location: getNodeLocation(node)
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
if (duration > MAX_TOAST_DURATION) {
|
|
1698
|
+
return {
|
|
1699
|
+
ruleId: "feedback-toast-duration",
|
|
1700
|
+
category: "feedback",
|
|
1701
|
+
severity: "info",
|
|
1702
|
+
message: `Toast duration (${duration}ms) may be too long`,
|
|
1703
|
+
description: "Long-lasting toasts can be annoying and block UI",
|
|
1704
|
+
suggestion: `Consider reducing duration or using a persistent alert instead`,
|
|
1705
|
+
path: context.path,
|
|
1706
|
+
nodeType: node.type,
|
|
1707
|
+
location: getNodeLocation(node)
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1714
|
+
var alertDismissible = {
|
|
1715
|
+
id: "feedback-alert-dismissible",
|
|
1716
|
+
category: "feedback",
|
|
1717
|
+
severity: "info",
|
|
1718
|
+
name: "Non-critical alerts should be dismissible",
|
|
1719
|
+
description: "Info and success alerts should be dismissible by users",
|
|
1720
|
+
appliesTo: ["Alert"],
|
|
1721
|
+
check: (node, context) => {
|
|
1722
|
+
const variant = "variant" in node ? String(node.variant || "") : "";
|
|
1723
|
+
const isDismissible = "dismissible" in node || "closable" in node || "onClose" in node;
|
|
1724
|
+
if (variant === "danger" || variant === "error") return null;
|
|
1725
|
+
if ((variant === "info" || variant === "success") && !isDismissible) {
|
|
1726
|
+
return {
|
|
1727
|
+
ruleId: "feedback-alert-dismissible",
|
|
1728
|
+
category: "feedback",
|
|
1729
|
+
severity: "info",
|
|
1730
|
+
message: `${variant} alert is not dismissible`,
|
|
1731
|
+
description: "Users should be able to dismiss non-critical alerts",
|
|
1732
|
+
suggestion: "Add dismissible or closable attribute to allow users to close the alert",
|
|
1733
|
+
path: context.path,
|
|
1734
|
+
nodeType: node.type,
|
|
1735
|
+
location: getNodeLocation(node)
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
var tooltipContentLength = {
|
|
1742
|
+
id: "feedback-tooltip-length",
|
|
1743
|
+
category: "feedback",
|
|
1744
|
+
severity: "info",
|
|
1745
|
+
name: "Tooltip content should be brief",
|
|
1746
|
+
description: "Tooltips should be short and helpful",
|
|
1747
|
+
appliesTo: ["Tooltip"],
|
|
1748
|
+
check: (node, context) => {
|
|
1749
|
+
const tooltipText = getNodeText(node);
|
|
1750
|
+
if (tooltipText.length > MAX_TOOLTIP_LENGTH) {
|
|
1751
|
+
return {
|
|
1752
|
+
ruleId: "feedback-tooltip-length",
|
|
1753
|
+
category: "feedback",
|
|
1754
|
+
severity: "info",
|
|
1755
|
+
message: `Tooltip is ${tooltipText.length} characters (recommended max: ${MAX_TOOLTIP_LENGTH})`,
|
|
1756
|
+
description: "Long tooltips are hard to read and may disappear before being fully read",
|
|
1757
|
+
suggestion: "Keep tooltips brief or use a popover for longer content",
|
|
1758
|
+
path: context.path,
|
|
1759
|
+
nodeType: node.type,
|
|
1760
|
+
location: getNodeLocation(node)
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
return null;
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
var formErrorFeedback = {
|
|
1767
|
+
id: "feedback-form-errors",
|
|
1768
|
+
category: "feedback",
|
|
1769
|
+
severity: "info",
|
|
1770
|
+
name: "Form should handle errors",
|
|
1771
|
+
description: "Forms should have a way to display validation errors",
|
|
1772
|
+
appliesTo: ["Card", "Section", "Modal"],
|
|
1773
|
+
check: (node, context) => {
|
|
1774
|
+
if (!hasChildren(node)) {
|
|
1775
|
+
return null;
|
|
1776
|
+
}
|
|
1777
|
+
const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
|
|
1778
|
+
const hasErrorDisplay = hasChildMatching(node, (child) => {
|
|
1779
|
+
if (FORM_INPUT_TYPES.includes(child.type)) {
|
|
1780
|
+
if ("error" in child || "errorText" in child || "helperText" in child) {
|
|
1781
|
+
return true;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (child.type === "Alert" && "variant" in child) {
|
|
1785
|
+
const variant = String(child.variant || "");
|
|
1786
|
+
if (variant === "error" || variant === "danger") {
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (child.type === "Text") {
|
|
1791
|
+
const content = getNodeText(child).toLowerCase();
|
|
1792
|
+
if (content.includes("error") || content.includes("invalid")) {
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return false;
|
|
1797
|
+
});
|
|
1798
|
+
if (hasInputs && !hasErrorDisplay) {
|
|
1799
|
+
return {
|
|
1800
|
+
ruleId: "feedback-form-errors",
|
|
1801
|
+
category: "feedback",
|
|
1802
|
+
severity: "info",
|
|
1803
|
+
message: "Form area has no visible error handling",
|
|
1804
|
+
description: "Users need to see validation errors when they occur",
|
|
1805
|
+
suggestion: "Add error/errorText attributes to inputs or include an Alert for form-level errors",
|
|
1806
|
+
path: context.path,
|
|
1807
|
+
nodeType: node.type,
|
|
1808
|
+
location: getNodeLocation(node)
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
var feedbackRules = [
|
|
1815
|
+
spinnerHasContext,
|
|
1816
|
+
progressHasValue,
|
|
1817
|
+
toastDuration,
|
|
1818
|
+
alertDismissible,
|
|
1819
|
+
tooltipContentLength,
|
|
1820
|
+
formErrorFeedback
|
|
1821
|
+
];
|
|
1822
|
+
|
|
1823
|
+
// src/rules/interaction.ts
|
|
1824
|
+
var buttonHasAction = {
|
|
1825
|
+
id: "interaction-button-action",
|
|
1826
|
+
category: "interaction",
|
|
1827
|
+
severity: "warning",
|
|
1828
|
+
name: "Button should have action",
|
|
1829
|
+
description: "Buttons should have an onClick, action, or navigation target defined",
|
|
1830
|
+
appliesTo: ["Button"],
|
|
1831
|
+
check: (node, context) => {
|
|
1832
|
+
const hasOnClick = "onClick" in node;
|
|
1833
|
+
const hasAction = "action" in node;
|
|
1834
|
+
const hasHref = "href" in node;
|
|
1835
|
+
const hasTo = "to" in node;
|
|
1836
|
+
const hasNavigate = "navigate" in node;
|
|
1837
|
+
const hasLink = "link" in node;
|
|
1838
|
+
const hasSubmit = "buttonType" in node && node.buttonType === "submit";
|
|
1839
|
+
const hasFormSubmit = "submit" in node;
|
|
1840
|
+
const buttonText = getNodeText(node);
|
|
1841
|
+
if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasNavigate && !hasLink && !hasSubmit && !hasFormSubmit) {
|
|
1842
|
+
return {
|
|
1843
|
+
ruleId: "interaction-button-action",
|
|
1844
|
+
category: "interaction",
|
|
1845
|
+
severity: "warning",
|
|
1846
|
+
message: buttonText ? `Button "${buttonText}" has no action defined` : "Button has no action defined",
|
|
1847
|
+
description: "Interactive buttons need an action to respond to user clicks",
|
|
1848
|
+
suggestion: "Add onClick, action, href, or navigate attribute to define what happens when clicked",
|
|
1849
|
+
path: context.path,
|
|
1850
|
+
nodeType: node.type,
|
|
1851
|
+
location: getNodeLocation(node)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
return null;
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
var linkHasDestination = {
|
|
1858
|
+
id: "interaction-link-destination",
|
|
1859
|
+
category: "interaction",
|
|
1860
|
+
severity: "warning",
|
|
1861
|
+
name: "Link should have destination",
|
|
1862
|
+
description: "Links should have an href or navigation target defined",
|
|
1863
|
+
appliesTo: ["Link", "NavLink", "Anchor"],
|
|
1864
|
+
check: (node, context) => {
|
|
1865
|
+
const hasHref = "href" in node && node.href;
|
|
1866
|
+
const hasTo = "to" in node && node.to;
|
|
1867
|
+
const hasNavigate = "navigate" in node;
|
|
1868
|
+
const hasUrl = "url" in node && node.url;
|
|
1869
|
+
const linkText = getNodeText(node);
|
|
1870
|
+
if (!hasHref && !hasTo && !hasNavigate && !hasUrl) {
|
|
1871
|
+
return {
|
|
1872
|
+
ruleId: "interaction-link-destination",
|
|
1873
|
+
category: "interaction",
|
|
1874
|
+
severity: "warning",
|
|
1875
|
+
message: linkText ? `Link "${linkText}" has no destination` : "Link has no destination defined",
|
|
1876
|
+
description: "Links need a destination URL or route to navigate to",
|
|
1877
|
+
suggestion: "Add href, to, or navigate attribute to define the link destination",
|
|
1878
|
+
path: context.path,
|
|
1879
|
+
nodeType: node.type,
|
|
1880
|
+
location: getNodeLocation(node)
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
var formHasSubmitAction = {
|
|
1887
|
+
id: "interaction-form-submit",
|
|
1888
|
+
category: "interaction",
|
|
1889
|
+
severity: "warning",
|
|
1890
|
+
name: "Form should have submit action",
|
|
1891
|
+
description: "Forms should have an onSubmit action or form action defined",
|
|
1892
|
+
appliesTo: ["Form"],
|
|
1893
|
+
check: (node, context) => {
|
|
1894
|
+
const hasOnSubmit = "onSubmit" in node;
|
|
1895
|
+
const hasAction = "action" in node && node.action;
|
|
1896
|
+
if (!hasOnSubmit && !hasAction) {
|
|
1897
|
+
return {
|
|
1898
|
+
ruleId: "interaction-form-submit",
|
|
1899
|
+
category: "interaction",
|
|
1900
|
+
severity: "warning",
|
|
1901
|
+
message: "Form has no submit action defined",
|
|
1902
|
+
description: "Forms need an action to handle submission",
|
|
1903
|
+
suggestion: "Add onSubmit or action attribute to define form submission behavior",
|
|
1904
|
+
path: context.path,
|
|
1905
|
+
nodeType: node.type,
|
|
1906
|
+
location: getNodeLocation(node)
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
return null;
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
var tabItemHasTarget = {
|
|
1913
|
+
id: "interaction-tab-target",
|
|
1914
|
+
category: "interaction",
|
|
1915
|
+
severity: "warning",
|
|
1916
|
+
name: "Tab item should have target",
|
|
1917
|
+
description: "Tab items should link to content or have an action",
|
|
1918
|
+
appliesTo: ["Tab", "TabItem"],
|
|
1919
|
+
check: (node, context) => {
|
|
1920
|
+
const hasTarget = "target" in node;
|
|
1921
|
+
const hasPanel = "panel" in node;
|
|
1922
|
+
const hasContent = "content" in node;
|
|
1923
|
+
const hasOnClick = "onClick" in node;
|
|
1924
|
+
const hasValue = "value" in node;
|
|
1925
|
+
const tabLabel = getNodeText(node);
|
|
1926
|
+
if (!hasTarget && !hasPanel && !hasContent && !hasOnClick && !hasValue) {
|
|
1927
|
+
return {
|
|
1928
|
+
ruleId: "interaction-tab-target",
|
|
1929
|
+
category: "interaction",
|
|
1930
|
+
severity: "warning",
|
|
1931
|
+
message: tabLabel ? `Tab "${tabLabel}" has no target or action` : "Tab item has no target or action defined",
|
|
1932
|
+
description: "Tab items need to reference content or trigger an action",
|
|
1933
|
+
suggestion: "Add target, panel, value, or onClick attribute",
|
|
1934
|
+
path: context.path,
|
|
1935
|
+
nodeType: node.type,
|
|
1936
|
+
location: getNodeLocation(node)
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
};
|
|
1942
|
+
var menuItemHasAction = {
|
|
1943
|
+
id: "interaction-menu-action",
|
|
1944
|
+
category: "interaction",
|
|
1945
|
+
severity: "warning",
|
|
1946
|
+
name: "Menu item should have action",
|
|
1947
|
+
description: "Menu items should have an onClick, href, or action defined",
|
|
1948
|
+
appliesTo: ["MenuItem", "DropdownItem"],
|
|
1949
|
+
check: (node, context) => {
|
|
1950
|
+
const hasOnClick = "onClick" in node;
|
|
1951
|
+
const hasAction = "action" in node;
|
|
1952
|
+
const hasHref = "href" in node;
|
|
1953
|
+
const hasTo = "to" in node;
|
|
1954
|
+
const hasCommand = "command" in node;
|
|
1955
|
+
const itemText = getNodeText(node);
|
|
1956
|
+
if ("divider" in node || "separator" in node) {
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasCommand) {
|
|
1960
|
+
return {
|
|
1961
|
+
ruleId: "interaction-menu-action",
|
|
1962
|
+
category: "interaction",
|
|
1963
|
+
severity: "warning",
|
|
1964
|
+
message: itemText ? `Menu item "${itemText}" has no action` : "Menu item has no action defined",
|
|
1965
|
+
description: "Menu items need an action to respond to selection",
|
|
1966
|
+
suggestion: "Add onClick, action, href, or command attribute",
|
|
1967
|
+
path: context.path,
|
|
1968
|
+
nodeType: node.type,
|
|
1969
|
+
location: getNodeLocation(node)
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
return null;
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
var clickableCardHasAction = {
|
|
1976
|
+
id: "interaction-card-action",
|
|
1977
|
+
category: "interaction",
|
|
1978
|
+
severity: "info",
|
|
1979
|
+
name: "Clickable card should have action",
|
|
1980
|
+
description: "Cards marked as clickable should have an action defined",
|
|
1981
|
+
appliesTo: ["Card"],
|
|
1982
|
+
check: (node, context) => {
|
|
1983
|
+
const isClickable = "clickable" in node || "hoverable" in node || "interactive" in node;
|
|
1984
|
+
if (!isClickable) return null;
|
|
1985
|
+
const hasOnClick = "onClick" in node;
|
|
1986
|
+
const hasHref = "href" in node;
|
|
1987
|
+
const hasTo = "to" in node;
|
|
1988
|
+
const hasAction = "action" in node;
|
|
1989
|
+
if (!hasOnClick && !hasHref && !hasTo && !hasAction) {
|
|
1990
|
+
return {
|
|
1991
|
+
ruleId: "interaction-card-action",
|
|
1992
|
+
category: "interaction",
|
|
1993
|
+
severity: "info",
|
|
1994
|
+
message: "Clickable card has no action defined",
|
|
1995
|
+
description: "Cards marked as clickable/interactive need an action",
|
|
1996
|
+
suggestion: "Add onClick, href, or action attribute to define click behavior",
|
|
1997
|
+
path: context.path,
|
|
1998
|
+
nodeType: node.type,
|
|
1999
|
+
location: getNodeLocation(node)
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
var iconButtonHasAction = {
|
|
2006
|
+
id: "interaction-icon-button-action",
|
|
2007
|
+
category: "interaction",
|
|
2008
|
+
severity: "warning",
|
|
2009
|
+
name: "Icon button should have action",
|
|
2010
|
+
description: "Icon buttons should have an action defined",
|
|
2011
|
+
appliesTo: ["IconButton"],
|
|
2012
|
+
check: (node, context) => {
|
|
2013
|
+
const hasOnClick = "onClick" in node;
|
|
2014
|
+
const hasAction = "action" in node;
|
|
2015
|
+
const hasHref = "href" in node;
|
|
2016
|
+
const hasTo = "to" in node;
|
|
2017
|
+
const iconName = "icon" in node ? String(node.icon || "") : "name" in node ? String(node.name || "") : "";
|
|
2018
|
+
if (!hasOnClick && !hasAction && !hasHref && !hasTo) {
|
|
2019
|
+
return {
|
|
2020
|
+
ruleId: "interaction-icon-button-action",
|
|
2021
|
+
category: "interaction",
|
|
2022
|
+
severity: "warning",
|
|
2023
|
+
message: iconName ? `Icon button "${iconName}" has no action defined` : "Icon button has no action defined",
|
|
2024
|
+
description: "Icon buttons need an action to respond to clicks",
|
|
2025
|
+
suggestion: "Add onClick, action, or href attribute",
|
|
2026
|
+
path: context.path,
|
|
2027
|
+
nodeType: node.type,
|
|
2028
|
+
location: getNodeLocation(node)
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
var modalHasCloseAction = {
|
|
2035
|
+
id: "interaction-modal-close",
|
|
2036
|
+
category: "interaction",
|
|
2037
|
+
severity: "info",
|
|
2038
|
+
name: "Modal should have close mechanism",
|
|
2039
|
+
description: "Modals should have a way to close them",
|
|
2040
|
+
appliesTo: ["Modal", "Dialog"],
|
|
2041
|
+
check: (node, context) => {
|
|
2042
|
+
const hasOnClose = "onClose" in node;
|
|
2043
|
+
const hasCloseButton = "closable" in node || "closeButton" in node;
|
|
2044
|
+
const hasDismiss = "dismissible" in node || "dismiss" in node;
|
|
2045
|
+
const hasCloseChild = hasChildren(node) && hasChildMatching(node, (child) => {
|
|
2046
|
+
if (child.type === "Button") {
|
|
2047
|
+
const text = "text" in child ? String(child.text || "").toLowerCase() : "";
|
|
2048
|
+
const label = "label" in child ? String(child.label || "").toLowerCase() : "";
|
|
2049
|
+
const action = "action" in child ? String(child.action || "").toLowerCase() : "";
|
|
2050
|
+
return text.includes("close") || text.includes("cancel") || label.includes("close") || label.includes("cancel") || action.includes("close");
|
|
2051
|
+
}
|
|
2052
|
+
return false;
|
|
2053
|
+
});
|
|
2054
|
+
if (!hasOnClose && !hasCloseButton && !hasDismiss && !hasCloseChild) {
|
|
2055
|
+
return {
|
|
2056
|
+
ruleId: "interaction-modal-close",
|
|
2057
|
+
category: "interaction",
|
|
2058
|
+
severity: "info",
|
|
2059
|
+
message: "Modal has no close mechanism defined",
|
|
2060
|
+
description: "Users need a way to close modals",
|
|
2061
|
+
suggestion: "Add onClose, closable attribute, or a close/cancel button",
|
|
2062
|
+
path: context.path,
|
|
2063
|
+
nodeType: node.type,
|
|
2064
|
+
location: getNodeLocation(node)
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
var interactionRules = [
|
|
2071
|
+
buttonHasAction,
|
|
2072
|
+
linkHasDestination,
|
|
2073
|
+
formHasSubmitAction,
|
|
2074
|
+
tabItemHasTarget,
|
|
2075
|
+
menuItemHasAction,
|
|
2076
|
+
clickableCardHasAction,
|
|
2077
|
+
iconButtonHasAction,
|
|
2078
|
+
modalHasCloseAction
|
|
2079
|
+
];
|
|
2080
|
+
|
|
1003
2081
|
// src/rules/index.ts
|
|
1004
2082
|
var allRules = [
|
|
1005
2083
|
...accessibilityRules,
|
|
@@ -1007,7 +2085,11 @@ var allRules = [
|
|
|
1007
2085
|
...touchTargetRules,
|
|
1008
2086
|
...consistencyRules,
|
|
1009
2087
|
...usabilityRules,
|
|
1010
|
-
...navigationRules
|
|
2088
|
+
...navigationRules,
|
|
2089
|
+
...contentRules,
|
|
2090
|
+
...dataDisplayRules,
|
|
2091
|
+
...feedbackRules,
|
|
2092
|
+
...interactionRules
|
|
1011
2093
|
];
|
|
1012
2094
|
var rulesByCategory = {
|
|
1013
2095
|
accessibility: accessibilityRules,
|
|
@@ -1015,7 +2097,11 @@ var rulesByCategory = {
|
|
|
1015
2097
|
"touch-target": touchTargetRules,
|
|
1016
2098
|
consistency: consistencyRules,
|
|
1017
2099
|
usability: usabilityRules,
|
|
1018
|
-
navigation: navigationRules
|
|
2100
|
+
navigation: navigationRules,
|
|
2101
|
+
content: contentRules,
|
|
2102
|
+
"data-display": dataDisplayRules,
|
|
2103
|
+
feedback: feedbackRules,
|
|
2104
|
+
interaction: interactionRules
|
|
1019
2105
|
};
|
|
1020
2106
|
function getRulesForCategories(categories) {
|
|
1021
2107
|
if (categories.length === 0) return allRules;
|
|
@@ -1039,7 +2125,6 @@ function validateUX(ast, options = {}) {
|
|
|
1039
2125
|
customRules = [],
|
|
1040
2126
|
disabledRules = []
|
|
1041
2127
|
} = options;
|
|
1042
|
-
resetConsistencyTrackers();
|
|
1043
2128
|
let rules = categories.length > 0 ? getRulesForCategories(categories) : allRules;
|
|
1044
2129
|
rules = [...rules, ...customRules];
|
|
1045
2130
|
if (disabledRules.length > 0) {
|
|
@@ -1076,10 +2161,11 @@ function validateUX(ast, options = {}) {
|
|
|
1076
2161
|
return true;
|
|
1077
2162
|
}
|
|
1078
2163
|
function walkNode(node, path, parent, siblings, index, depth) {
|
|
2164
|
+
const rootNode = ast.children?.[0];
|
|
1079
2165
|
const context = {
|
|
1080
2166
|
path,
|
|
1081
2167
|
parent,
|
|
1082
|
-
root:
|
|
2168
|
+
root: isAnyNode(rootNode) ? rootNode : null,
|
|
1083
2169
|
siblings,
|
|
1084
2170
|
index,
|
|
1085
2171
|
depth
|
|
@@ -1124,9 +2210,9 @@ function validateUX(ast, options = {}) {
|
|
|
1124
2210
|
return true;
|
|
1125
2211
|
}
|
|
1126
2212
|
if (ast.children) {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
2213
|
+
const children = toAnyNodeArray(ast.children);
|
|
2214
|
+
for (let i = 0; i < children.length; i++) {
|
|
2215
|
+
const page = children[i];
|
|
1130
2216
|
walkNode(page, `pages[${i}]`, null, children, i, 0);
|
|
1131
2217
|
}
|
|
1132
2218
|
}
|
|
@@ -1191,12 +2277,59 @@ function formatUXResult(result) {
|
|
|
1191
2277
|
return lines.join("\n");
|
|
1192
2278
|
}
|
|
1193
2279
|
export {
|
|
2280
|
+
ASYNC_ACTION_WORDS,
|
|
2281
|
+
CLOSE_WORDS,
|
|
2282
|
+
CONTAINER_TYPES,
|
|
2283
|
+
DESTRUCTIVE_WORDS,
|
|
2284
|
+
ERROR_WORDS,
|
|
2285
|
+
FORM_INPUT_TYPES,
|
|
2286
|
+
GENERIC_LINK_TEXTS,
|
|
2287
|
+
HOME_WORDS,
|
|
2288
|
+
INPUT_TYPE_SUGGESTIONS,
|
|
2289
|
+
MAX_BUTTONS,
|
|
2290
|
+
MAX_BUTTON_TEXT_LENGTH,
|
|
2291
|
+
MAX_FORM_FIELDS,
|
|
2292
|
+
MAX_LIST_ITEMS,
|
|
2293
|
+
MAX_NAV_ITEMS,
|
|
2294
|
+
MAX_NESTING_DEPTH,
|
|
2295
|
+
MAX_PAGE_ELEMENTS,
|
|
2296
|
+
MAX_TABLE_COLUMNS,
|
|
2297
|
+
MAX_TABS,
|
|
2298
|
+
MAX_TITLE_LENGTH,
|
|
2299
|
+
MAX_TOAST_DURATION,
|
|
2300
|
+
MAX_TOOLTIP_LENGTH,
|
|
2301
|
+
MIN_TOAST_DURATION,
|
|
2302
|
+
MIN_TOUCH_TARGET,
|
|
2303
|
+
PLACEHOLDER_PATTERNS,
|
|
2304
|
+
RECOMMENDED_TOUCH_TARGET,
|
|
2305
|
+
SIZE_MAP,
|
|
2306
|
+
SUBMIT_WORDS,
|
|
2307
|
+
SUCCESS_WORDS,
|
|
2308
|
+
TEXT_CONTENT_TYPES,
|
|
2309
|
+
WARNING_WORDS,
|
|
1194
2310
|
allRules,
|
|
2311
|
+
containsAnyWord,
|
|
2312
|
+
countInChildren,
|
|
2313
|
+
filterChildren,
|
|
2314
|
+
findInChildren,
|
|
1195
2315
|
formatUXResult,
|
|
2316
|
+
getButtonStyle,
|
|
2317
|
+
getChildren,
|
|
2318
|
+
getNodeItems,
|
|
2319
|
+
getNodeLocation,
|
|
2320
|
+
getNodeText,
|
|
2321
|
+
getNodeTextLower,
|
|
1196
2322
|
getRulesForCategories,
|
|
2323
|
+
getSizeValue,
|
|
1197
2324
|
getUXIssues,
|
|
1198
2325
|
getUXScore,
|
|
2326
|
+
hasChildMatching,
|
|
2327
|
+
hasChildren,
|
|
2328
|
+
isAnyNode,
|
|
2329
|
+
isNodeType,
|
|
1199
2330
|
isUXValid,
|
|
2331
|
+
matchesAnyWord,
|
|
1200
2332
|
rulesByCategory,
|
|
2333
|
+
toAnyNodeArray,
|
|
1201
2334
|
validateUX
|
|
1202
2335
|
};
|