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