@abstraks-dev/ui-library 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +708 -0
- package/dist/__tests__/Anchor.test.js +145 -0
- package/dist/__tests__/ArrowRight.test.js +91 -0
- package/dist/__tests__/Avatar.test.js +123 -0
- package/dist/__tests__/Button.test.js +82 -0
- package/dist/__tests__/Card.test.js +198 -0
- package/dist/__tests__/CheckCircle.test.js +98 -0
- package/dist/__tests__/Checkbox.test.js +161 -0
- package/dist/__tests__/ChevronDown.test.js +73 -0
- package/dist/__tests__/Close.test.js +98 -0
- package/dist/__tests__/EditSquare.test.js +99 -0
- package/dist/__tests__/Error.test.js +74 -0
- package/dist/__tests__/Footer.test.js +66 -0
- package/dist/__tests__/Heading.test.js +227 -0
- package/dist/__tests__/Hero.test.js +74 -0
- package/dist/__tests__/Label.test.js +123 -0
- package/dist/__tests__/Loader.test.js +115 -0
- package/dist/__tests__/MenuHover.test.js +137 -0
- package/dist/__tests__/Paragraph.test.js +93 -0
- package/dist/__tests__/PlusCircle.test.js +99 -0
- package/dist/__tests__/Radio.test.js +153 -0
- package/dist/__tests__/Select.test.js +187 -0
- package/dist/__tests__/Tabs.test.js +162 -0
- package/dist/__tests__/TextArea.test.js +127 -0
- package/dist/__tests__/TextInput.test.js +181 -0
- package/dist/__tests__/Toggle.test.js +120 -0
- package/dist/__tests__/TrashX.test.js +99 -0
- package/dist/__tests__/useHeadingAccessibility.test.js +144 -0
- package/dist/components/Anchor.js +131 -0
- package/dist/components/Animation.js +129 -0
- package/dist/components/AnimationGroup.js +207 -0
- package/dist/components/AnimationToggle.js +216 -0
- package/dist/components/Avatar.js +153 -0
- package/dist/components/Button.js +218 -0
- package/dist/components/Card.js +222 -0
- package/dist/components/Checkbox.js +305 -0
- package/dist/components/Crud.js +564 -0
- package/dist/components/DragAndDrop.js +337 -0
- package/dist/components/Error.js +206 -0
- package/dist/components/Footer.js +99 -0
- package/dist/components/Form.js +412 -0
- package/dist/components/Header.js +372 -0
- package/dist/components/Heading.js +134 -0
- package/dist/components/Hero.js +181 -0
- package/dist/components/Label.js +256 -0
- package/dist/components/Loader.js +302 -0
- package/dist/components/MenuHover.js +114 -0
- package/dist/components/Paragraph.js +128 -0
- package/dist/components/Prompt.js +61 -0
- package/dist/components/Radio.js +254 -0
- package/dist/components/Select.js +422 -0
- package/dist/components/SideMenu.js +313 -0
- package/dist/components/Tabs.js +297 -0
- package/dist/components/TextArea.js +370 -0
- package/dist/components/TextInput.js +286 -0
- package/dist/components/Toggle.js +186 -0
- package/dist/components/crudFiles/CrudEditBase.js +150 -0
- package/dist/components/crudFiles/CrudViewBase.js +39 -0
- package/dist/components/crudFiles/crudDevelopment.js +118 -0
- package/dist/components/crudFiles/crudEditHandlers.js +50 -0
- package/dist/constants/animation.js +30 -0
- package/dist/icons/ArrowIcon.js +32 -0
- package/dist/icons/ArrowRight.js +33 -0
- package/dist/icons/CheckCircle.js +33 -0
- package/dist/icons/ChevronDown.js +28 -0
- package/dist/icons/Close.js +33 -0
- package/dist/icons/EditSquare.js +33 -0
- package/dist/icons/Ellipses.js +34 -0
- package/dist/icons/Hamburger.js +39 -0
- package/dist/icons/LoadingSpinner.js +42 -0
- package/dist/icons/PlusCircle.js +33 -0
- package/dist/icons/SaveIcon.js +32 -0
- package/dist/icons/TrashX.js +33 -0
- package/dist/icons/__tests__/CheckCircle.test.js +9 -0
- package/dist/icons/__tests__/ChevronDown.test.js +9 -0
- package/dist/icons/__tests__/Close.test.js +9 -0
- package/dist/icons/__tests__/EditSquare.test.js +9 -0
- package/dist/icons/__tests__/PlusCircle.test.js +9 -0
- package/dist/icons/__tests__/TrashX.test.js +9 -0
- package/dist/icons/index.js +89 -0
- package/dist/index.js +332 -0
- package/dist/setupTests.js +3 -0
- package/dist/styles/_variables.scss +286 -0
- package/dist/styles/anchor.scss +40 -0
- package/dist/styles/animation-accessibility.scss +96 -0
- package/dist/styles/animation-toggle.scss +233 -0
- package/dist/styles/animation.scss +3781 -0
- package/dist/styles/avatar.scss +285 -0
- package/dist/styles/button.scss +430 -0
- package/dist/styles/card.scss +210 -0
- package/dist/styles/checkbox.scss +160 -0
- package/dist/styles/crud.scss +474 -0
- package/dist/styles/dragAndDrop.scss +312 -0
- package/dist/styles/error.scss +232 -0
- package/dist/styles/footer.scss +58 -0
- package/dist/styles/form.scss +420 -0
- package/dist/styles/grid.scss +29 -0
- package/dist/styles/header.scss +276 -0
- package/dist/styles/heading.scss +118 -0
- package/dist/styles/hero.scss +185 -0
- package/dist/styles/htmlElements.scss +20 -0
- package/dist/styles/image.scss +9 -0
- package/dist/styles/label.scss +340 -0
- package/dist/styles/list-item.scss +5 -0
- package/dist/styles/loader.scss +354 -0
- package/dist/styles/logo.scss +19 -0
- package/dist/styles/main.css +9056 -0
- package/dist/styles/main.css.map +1 -0
- package/dist/styles/main.scss +0 -0
- package/dist/styles/menu-hover.scss +30 -0
- package/dist/styles/paragraph.scss +88 -0
- package/dist/styles/prompt.scss +51 -0
- package/dist/styles/radio.scss +202 -0
- package/dist/styles/select.scss +363 -0
- package/dist/styles/side-menu.scss +334 -0
- package/dist/styles/tabs.scss +540 -0
- package/dist/styles/text-area.scss +388 -0
- package/dist/styles/text-input.scss +171 -0
- package/dist/styles/toggle.scss +0 -0
- package/dist/styles/unordered-list.scss +8 -0
- package/dist/utils/ScrollHandler.js +30 -0
- package/dist/utils/accessibility.js +128 -0
- package/dist/utils/heroUtils.js +316 -0
- package/dist/utils/index.js +104 -0
- package/dist/utils/inputValidation.js +29 -0
- package/dist/utils/keyboardNavigation.js +536 -0
- package/dist/utils/labelUtils.js +708 -0
- package/dist/utils/loaderUtils.js +387 -0
- package/dist/utils/menuUtils.js +575 -0
- package/dist/utils/useHeadingAccessibility.js +298 -0
- package/dist/utils/useRadioGroup.js +260 -0
- package/dist/utils/useSelectAccessibility.js +426 -0
- package/dist/utils/useTabsAccessibility.js +278 -0
- package/dist/utils/useTextAreaAccessibility.js +255 -0
- package/dist/utils/useTextInputAccessibility.js +295 -0
- package/dist/utils/useTypographyAccessibility.js +168 -0
- package/dist/utils/useWindowSize.js +32 -0
- package/dist/utils/utils/ScrollHandler.js +26 -0
- package/dist/utils/utils/accessibility.js +133 -0
- package/dist/utils/utils/heroUtils.js +348 -0
- package/dist/utils/utils/index.js +9 -0
- package/dist/utils/utils/inputValidation.js +22 -0
- package/dist/utils/utils/keyboardNavigation.js +664 -0
- package/dist/utils/utils/labelUtils.js +772 -0
- package/dist/utils/utils/loaderUtils.js +436 -0
- package/dist/utils/utils/menuUtils.js +651 -0
- package/dist/utils/utils/useHeadingAccessibility.js +334 -0
- package/dist/utils/utils/useRadioGroup.js +311 -0
- package/dist/utils/utils/useSelectAccessibility.js +498 -0
- package/dist/utils/utils/useTabsAccessibility.js +316 -0
- package/dist/utils/utils/useTextAreaAccessibility.js +303 -0
- package/dist/utils/utils/useTextInputAccessibility.js +338 -0
- package/dist/utils/utils/useTypographyAccessibility.js +180 -0
- package/dist/utils/utils/useWindowSize.js +26 -0
- package/dist/utils/utils/validation.js +131 -0
- package/dist/utils/validation.js +139 -0
- package/package.json +90 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './ScrollHandler';
|
|
2
|
+
export * from './useWindowSize';
|
|
3
|
+
export * from './accessibility';
|
|
4
|
+
export * from './validation';
|
|
5
|
+
export * from './keyboardNavigation';
|
|
6
|
+
export * from './useHeadingAccessibility';
|
|
7
|
+
export * from './useTypographyAccessibility';
|
|
8
|
+
export * from './useRadioGroup';
|
|
9
|
+
export * from './useSelectAccessibility';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// inputValidation.js
|
|
2
|
+
// Utility functions for input validation
|
|
3
|
+
import isEmail from 'validator/lib/isEmail';
|
|
4
|
+
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|
5
|
+
|
|
6
|
+
export function validateEmail(value) {
|
|
7
|
+
return isEmail(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validatePhone(value, country = 'US') {
|
|
11
|
+
return isValidPhoneNumber(value, country);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateLength(value, minLength, maxLength) {
|
|
15
|
+
if (minLength && value.length < minLength) return false;
|
|
16
|
+
if (maxLength && value.length > maxLength) return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateRequired(value) {
|
|
21
|
+
return value.trim().length > 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard Navigation Testing Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides helper functions for testing keyboard accessibility in components.
|
|
5
|
+
* These utilities help ensure WCAG 2.1 AA compliance for keyboard navigation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Essential keyboard event codes for navigation testing
|
|
10
|
+
* Simplified to most commonly used keys
|
|
11
|
+
*/
|
|
12
|
+
export const KEYBOARD_KEYS = {
|
|
13
|
+
TAB: 'Tab',
|
|
14
|
+
ENTER: 'Enter',
|
|
15
|
+
SPACE: ' ',
|
|
16
|
+
ESCAPE: 'Escape',
|
|
17
|
+
ARROW_UP: 'ArrowUp',
|
|
18
|
+
ARROW_DOWN: 'ArrowDown',
|
|
19
|
+
ARROW_LEFT: 'ArrowLeft',
|
|
20
|
+
ARROW_RIGHT: 'ArrowRight',
|
|
21
|
+
HOME: 'Home',
|
|
22
|
+
END: 'End',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Common accessibility test patterns
|
|
27
|
+
*/
|
|
28
|
+
export const A11Y_PATTERNS = {
|
|
29
|
+
// ARIA roles that require specific keyboard behaviors
|
|
30
|
+
ROVING_TABINDEX: ['tablist', 'menubar', 'listbox', 'tree', 'grid'],
|
|
31
|
+
SINGLE_TAB_STOP: ['radiogroup', 'tablist', 'toolbar'],
|
|
32
|
+
ARROW_NAVIGATION: ['menu', 'menubar', 'listbox', 'tree', 'tablist'],
|
|
33
|
+
|
|
34
|
+
// Required ARIA attributes for interactive elements
|
|
35
|
+
REQUIRED_ARIA: {
|
|
36
|
+
button: [],
|
|
37
|
+
menuitem: ['role'],
|
|
38
|
+
tab: ['role', 'aria-selected'],
|
|
39
|
+
tabpanel: ['role', 'aria-labelledby'],
|
|
40
|
+
listbox: ['role'],
|
|
41
|
+
option: ['role', 'aria-selected'],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tests keyboard navigation for a component
|
|
47
|
+
* @param {HTMLElement} container - The container element to test
|
|
48
|
+
* @param {Object} options - Testing options
|
|
49
|
+
* @returns {Object} Test results
|
|
50
|
+
*/
|
|
51
|
+
export const testKeyboardNavigation = (container, options = {}) => {
|
|
52
|
+
const {
|
|
53
|
+
expectedFocusableCount = null,
|
|
54
|
+
shouldTrapFocus = false,
|
|
55
|
+
customKeys = [],
|
|
56
|
+
skipNativeElements = false,
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const results = {
|
|
60
|
+
passed: true,
|
|
61
|
+
errors: [],
|
|
62
|
+
focusableElements: [],
|
|
63
|
+
tabOrder: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Get all focusable elements
|
|
68
|
+
const focusableSelectors = [
|
|
69
|
+
'a[href]',
|
|
70
|
+
'button:not([disabled])',
|
|
71
|
+
'input:not([disabled])',
|
|
72
|
+
'select:not([disabled])',
|
|
73
|
+
'textarea:not([disabled])',
|
|
74
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
75
|
+
'[role="button"]:not([disabled])',
|
|
76
|
+
'[role="link"]:not([disabled])',
|
|
77
|
+
'[role="menuitem"]:not([disabled])',
|
|
78
|
+
'[role="tab"]:not([disabled])',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const focusableElements = container.querySelectorAll(
|
|
82
|
+
focusableSelectors.join(', ')
|
|
83
|
+
);
|
|
84
|
+
results.focusableElements = Array.from(focusableElements);
|
|
85
|
+
|
|
86
|
+
// Check expected count
|
|
87
|
+
if (
|
|
88
|
+
expectedFocusableCount !== null &&
|
|
89
|
+
focusableElements.length !== expectedFocusableCount
|
|
90
|
+
) {
|
|
91
|
+
results.passed = false;
|
|
92
|
+
results.errors.push(
|
|
93
|
+
`Expected ${expectedFocusableCount} focusable elements, found ${focusableElements.length}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Test tab order
|
|
98
|
+
focusableElements.forEach((element, index) => {
|
|
99
|
+
const tabIndex = element.getAttribute('tabindex');
|
|
100
|
+
results.tabOrder.push({
|
|
101
|
+
index,
|
|
102
|
+
element: element.tagName.toLowerCase(),
|
|
103
|
+
tabIndex: tabIndex ? parseInt(tabIndex) : 0,
|
|
104
|
+
ariaLabel: element.getAttribute('aria-label'),
|
|
105
|
+
text: element.textContent?.trim().substring(0, 50),
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Check for focus indicators
|
|
110
|
+
focusableElements.forEach((element, index) => {
|
|
111
|
+
const computedStyle = window.getComputedStyle(element, ':focus');
|
|
112
|
+
const hasOutline =
|
|
113
|
+
computedStyle.outline !== 'none' && computedStyle.outline !== '';
|
|
114
|
+
const hasBoxShadow = computedStyle.boxShadow !== 'none';
|
|
115
|
+
const hasCustomFocus =
|
|
116
|
+
element.classList.contains('focus-visible') ||
|
|
117
|
+
element.hasAttribute('data-focus-visible');
|
|
118
|
+
|
|
119
|
+
if (!hasOutline && !hasBoxShadow && !hasCustomFocus) {
|
|
120
|
+
results.passed = false;
|
|
121
|
+
results.errors.push(
|
|
122
|
+
`Element ${index + 1} (${
|
|
123
|
+
element.tagName
|
|
124
|
+
}) lacks visible focus indicator`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Test ARIA attributes
|
|
130
|
+
focusableElements.forEach((element, index) => {
|
|
131
|
+
const role = element.getAttribute('role');
|
|
132
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
133
|
+
const ariaLabelledBy = element.getAttribute('aria-labelledby');
|
|
134
|
+
const ariaDescribedBy = element.getAttribute('aria-describedby');
|
|
135
|
+
|
|
136
|
+
// Interactive elements should have accessible names
|
|
137
|
+
if (!ariaLabel && !ariaLabelledBy && !element.textContent?.trim()) {
|
|
138
|
+
const tagName = element.tagName.toLowerCase();
|
|
139
|
+
if (['button', 'a'].includes(tagName) || role) {
|
|
140
|
+
results.passed = false;
|
|
141
|
+
results.errors.push(
|
|
142
|
+
`Element ${index + 1} (${tagName}) lacks accessible name`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
results.passed = false;
|
|
149
|
+
results.errors.push(`Test error: ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return results;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Simulates keyboard events for testing
|
|
157
|
+
* @param {HTMLElement} element - Element to receive the event
|
|
158
|
+
* @param {string} key - Key code to simulate
|
|
159
|
+
* @param {Object} options - Event options
|
|
160
|
+
*/
|
|
161
|
+
export const simulateKeyPress = (element, key, options = {}) => {
|
|
162
|
+
const {
|
|
163
|
+
shiftKey = false,
|
|
164
|
+
ctrlKey = false,
|
|
165
|
+
altKey = false,
|
|
166
|
+
metaKey = false,
|
|
167
|
+
preventDefault = true,
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
const event = new KeyboardEvent('keydown', {
|
|
171
|
+
key,
|
|
172
|
+
code: key,
|
|
173
|
+
shiftKey,
|
|
174
|
+
ctrlKey,
|
|
175
|
+
altKey,
|
|
176
|
+
metaKey,
|
|
177
|
+
bubbles: true,
|
|
178
|
+
cancelable: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (preventDefault) {
|
|
182
|
+
event.preventDefault = () => {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
element.dispatchEvent(event);
|
|
186
|
+
return event;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Tests focus trapping in modal/dropdown components
|
|
191
|
+
* @param {HTMLElement} container - The container that should trap focus
|
|
192
|
+
* @returns {Object} Test results
|
|
193
|
+
*/
|
|
194
|
+
export const testFocusTrapping = (container) => {
|
|
195
|
+
const results = {
|
|
196
|
+
passed: true,
|
|
197
|
+
errors: [],
|
|
198
|
+
firstFocusable: null,
|
|
199
|
+
lastFocusable: null,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const focusableElements = container.querySelectorAll(
|
|
204
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (focusableElements.length === 0) {
|
|
208
|
+
results.passed = false;
|
|
209
|
+
results.errors.push('No focusable elements found for focus trapping');
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
results.firstFocusable = focusableElements[0];
|
|
214
|
+
results.lastFocusable = focusableElements[focusableElements.length - 1];
|
|
215
|
+
|
|
216
|
+
// Test that first element receives focus on container open
|
|
217
|
+
// Test that focus cycles from last to first element
|
|
218
|
+
// Test that focus cycles from first to last element (shift+tab)
|
|
219
|
+
// These would need to be implemented in actual test scenarios
|
|
220
|
+
} catch (error) {
|
|
221
|
+
results.passed = false;
|
|
222
|
+
results.errors.push(`Focus trapping test error: ${error.message}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return results;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validates ARIA attributes for dropdown/menu components
|
|
230
|
+
* @param {HTMLElement} trigger - The trigger element
|
|
231
|
+
* @param {HTMLElement} menu - The menu/dropdown element
|
|
232
|
+
* @returns {Object} Validation results
|
|
233
|
+
*/
|
|
234
|
+
export const validateMenuARIA = (trigger, menu) => {
|
|
235
|
+
const results = {
|
|
236
|
+
passed: true,
|
|
237
|
+
errors: [],
|
|
238
|
+
warnings: [],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Check trigger attributes
|
|
243
|
+
const ariaExpanded = trigger.getAttribute('aria-expanded');
|
|
244
|
+
const ariaHaspopup = trigger.getAttribute('aria-haspopup');
|
|
245
|
+
const ariaControls = trigger.getAttribute('aria-controls');
|
|
246
|
+
|
|
247
|
+
if (!ariaExpanded) {
|
|
248
|
+
results.passed = false;
|
|
249
|
+
results.errors.push('Trigger missing aria-expanded attribute');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!ariaHaspopup) {
|
|
253
|
+
results.warnings.push(
|
|
254
|
+
'Trigger missing aria-haspopup attribute (recommended)'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!ariaControls) {
|
|
259
|
+
results.warnings.push(
|
|
260
|
+
'Trigger missing aria-controls attribute (recommended)'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check menu attributes
|
|
265
|
+
const menuRole = menu.getAttribute('role');
|
|
266
|
+
const menuId = menu.getAttribute('id');
|
|
267
|
+
const ariaLabelledBy = menu.getAttribute('aria-labelledby');
|
|
268
|
+
|
|
269
|
+
if (!menuRole || !['menu', 'listbox', 'tree'].includes(menuRole)) {
|
|
270
|
+
results.passed = false;
|
|
271
|
+
results.errors.push('Menu missing appropriate role attribute');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (ariaControls && !menuId) {
|
|
275
|
+
results.passed = false;
|
|
276
|
+
results.errors.push(
|
|
277
|
+
'Menu missing id attribute (referenced by aria-controls)'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (ariaControls && menuId && ariaControls !== menuId) {
|
|
282
|
+
results.passed = false;
|
|
283
|
+
results.errors.push('aria-controls value does not match menu id');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check menu items
|
|
287
|
+
const menuItems = menu.querySelectorAll(
|
|
288
|
+
'[role="menuitem"], [role="option"]'
|
|
289
|
+
);
|
|
290
|
+
menuItems.forEach((item, index) => {
|
|
291
|
+
if (!item.getAttribute('role')) {
|
|
292
|
+
results.warnings.push(`Menu item ${index + 1} missing role attribute`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!item.getAttribute('aria-label') && !item.textContent?.trim()) {
|
|
296
|
+
results.passed = false;
|
|
297
|
+
results.errors.push(`Menu item ${index + 1} lacks accessible name`);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
results.passed = false;
|
|
302
|
+
results.errors.push(`ARIA validation error: ${error.message}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return results;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* High contrast mode detection for testing
|
|
310
|
+
* @returns {boolean} Whether high contrast mode is active
|
|
311
|
+
*/
|
|
312
|
+
export const isHighContrastMode = () => {
|
|
313
|
+
// Create a test element to detect high contrast mode
|
|
314
|
+
const testElement = document.createElement('div');
|
|
315
|
+
testElement.style.borderWidth = '1px';
|
|
316
|
+
testElement.style.borderStyle = 'solid';
|
|
317
|
+
testElement.style.borderColor = 'red green blue';
|
|
318
|
+
testElement.style.position = 'absolute';
|
|
319
|
+
testElement.style.left = '-9999px';
|
|
320
|
+
|
|
321
|
+
document.body.appendChild(testElement);
|
|
322
|
+
|
|
323
|
+
const computedStyle = window.getComputedStyle(testElement);
|
|
324
|
+
const borderColors = [
|
|
325
|
+
computedStyle.borderTopColor,
|
|
326
|
+
computedStyle.borderRightColor,
|
|
327
|
+
computedStyle.borderBottomColor,
|
|
328
|
+
computedStyle.borderLeftColor,
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
document.body.removeChild(testElement);
|
|
332
|
+
|
|
333
|
+
// In high contrast mode, all borders will be the same color
|
|
334
|
+
const allSameColor = borderColors.every((color) => color === borderColors[0]);
|
|
335
|
+
return allSameColor && borderColors[0] !== 'rgba(0, 0, 0, 0)';
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Generates a keyboard navigation test report
|
|
340
|
+
* @param {Object} testResults - Results from testKeyboardNavigation
|
|
341
|
+
* @returns {string} Formatted report
|
|
342
|
+
*/
|
|
343
|
+
export const generateTestReport = (testResults) => {
|
|
344
|
+
const { passed, errors, focusableElements, tabOrder } = testResults;
|
|
345
|
+
|
|
346
|
+
let report = `🔍 Keyboard Navigation Test Report\n`;
|
|
347
|
+
report += `Status: ${passed ? '✅ PASSED' : '❌ FAILED'}\n`;
|
|
348
|
+
report += `Focusable Elements: ${focusableElements.length}\n\n`;
|
|
349
|
+
|
|
350
|
+
if (errors.length > 0) {
|
|
351
|
+
report += `❌ Errors:\n`;
|
|
352
|
+
errors.forEach((error, index) => {
|
|
353
|
+
report += `${index + 1}. ${error}\n`;
|
|
354
|
+
});
|
|
355
|
+
report += '\n';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
report += `📋 Tab Order:\n`;
|
|
359
|
+
tabOrder.forEach((item, index) => {
|
|
360
|
+
report += `${index + 1}. ${item.element}`;
|
|
361
|
+
if (item.ariaLabel) report += ` (${item.ariaLabel})`;
|
|
362
|
+
if (item.text) report += ` - "${item.text}"`;
|
|
363
|
+
report += '\n';
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return report;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Tests roving tabindex behavior (common in toolbars, tab lists, etc.)
|
|
371
|
+
* @param {HTMLElement} container - Container with roving tabindex pattern
|
|
372
|
+
* @param {Object} options - Testing options
|
|
373
|
+
* @returns {Object} Test results
|
|
374
|
+
*/
|
|
375
|
+
export const testRovingTabindex = (container, options = {}) => {
|
|
376
|
+
const {
|
|
377
|
+
expectedActiveIndex = 0,
|
|
378
|
+
orientation = 'horizontal', // 'horizontal', 'vertical', 'both'
|
|
379
|
+
wraparound = true,
|
|
380
|
+
} = options;
|
|
381
|
+
|
|
382
|
+
const results = {
|
|
383
|
+
passed: true,
|
|
384
|
+
errors: [],
|
|
385
|
+
warnings: [],
|
|
386
|
+
activeElement: null,
|
|
387
|
+
tabbableElements: [],
|
|
388
|
+
focusableElements: [],
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
// Find all elements that should participate in roving tabindex
|
|
393
|
+
const allElements = container.querySelectorAll(
|
|
394
|
+
'[role="tab"], [role="menuitem"], [role="option"], .roving-tabindex'
|
|
395
|
+
);
|
|
396
|
+
results.focusableElements = Array.from(allElements);
|
|
397
|
+
|
|
398
|
+
if (allElements.length === 0) {
|
|
399
|
+
results.passed = false;
|
|
400
|
+
results.errors.push('No elements found for roving tabindex pattern');
|
|
401
|
+
return results;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check that only one element has tabindex="0" (is tabbable)
|
|
405
|
+
const tabbableElements = Array.from(allElements).filter(
|
|
406
|
+
(el) =>
|
|
407
|
+
el.getAttribute('tabindex') === '0' ||
|
|
408
|
+
(!el.hasAttribute('tabindex') && el.tabIndex === 0)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const nonTabbableElements = Array.from(allElements).filter(
|
|
412
|
+
(el) => el.getAttribute('tabindex') === '-1'
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
results.tabbableElements = tabbableElements;
|
|
416
|
+
|
|
417
|
+
if (tabbableElements.length !== 1) {
|
|
418
|
+
results.passed = false;
|
|
419
|
+
results.errors.push(
|
|
420
|
+
`Expected exactly 1 tabbable element, found ${tabbableElements.length}`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (
|
|
425
|
+
tabbableElements.length + nonTabbableElements.length !==
|
|
426
|
+
allElements.length
|
|
427
|
+
) {
|
|
428
|
+
results.passed = false;
|
|
429
|
+
results.errors.push(
|
|
430
|
+
'Some elements missing explicit tabindex values (-1 or 0)'
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Verify active element matches expected
|
|
435
|
+
if (tabbableElements.length === 1) {
|
|
436
|
+
results.activeElement = tabbableElements[0];
|
|
437
|
+
const activeIndex = Array.from(allElements).indexOf(
|
|
438
|
+
results.activeElement
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (expectedActiveIndex !== null && activeIndex !== expectedActiveIndex) {
|
|
442
|
+
results.warnings.push(
|
|
443
|
+
`Expected element ${expectedActiveIndex} to be active, but element ${activeIndex} is active`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check for proper ARIA attributes
|
|
449
|
+
allElements.forEach((element, index) => {
|
|
450
|
+
const role = element.getAttribute('role');
|
|
451
|
+
|
|
452
|
+
if (!role) {
|
|
453
|
+
results.warnings.push(
|
|
454
|
+
`Element ${index} missing role attribute for roving tabindex`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Check for aria-selected on tabs/options
|
|
459
|
+
if (
|
|
460
|
+
['tab', 'option'].includes(role) &&
|
|
461
|
+
!element.hasAttribute('aria-selected')
|
|
462
|
+
) {
|
|
463
|
+
results.passed = false;
|
|
464
|
+
results.errors.push(
|
|
465
|
+
`Element ${index} with role="${role}" missing aria-selected attribute`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
results.passed = false;
|
|
471
|
+
results.errors.push(`Roving tabindex test error: ${error.message}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return results;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Tests arrow key navigation patterns
|
|
479
|
+
* @param {HTMLElement} container - Container with arrow navigation
|
|
480
|
+
* @param {string} expectedPattern - Expected navigation pattern ('horizontal', 'vertical', 'both', 'grid')
|
|
481
|
+
* @returns {Object} Test results
|
|
482
|
+
*/
|
|
483
|
+
export const testArrowKeyNavigation = (
|
|
484
|
+
container,
|
|
485
|
+
expectedPattern = 'horizontal'
|
|
486
|
+
) => {
|
|
487
|
+
const results = {
|
|
488
|
+
passed: true,
|
|
489
|
+
errors: [],
|
|
490
|
+
warnings: [],
|
|
491
|
+
pattern: expectedPattern,
|
|
492
|
+
navigableElements: [],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
// Find navigable elements
|
|
497
|
+
const elements = container.querySelectorAll(
|
|
498
|
+
'[role="tab"], [role="menuitem"], [role="option"], .arrow-navigable'
|
|
499
|
+
);
|
|
500
|
+
results.navigableElements = Array.from(elements);
|
|
501
|
+
|
|
502
|
+
if (elements.length < 2) {
|
|
503
|
+
results.warnings.push(
|
|
504
|
+
'Need at least 2 elements to test arrow navigation'
|
|
505
|
+
);
|
|
506
|
+
return results;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Check if elements have proper event handlers (this is tricky to test statically)
|
|
510
|
+
// In real implementation, we'd simulate arrow key events and check focus changes
|
|
511
|
+
|
|
512
|
+
// Check for proper ARIA orientation attribute
|
|
513
|
+
const orientationAttr = container.getAttribute('aria-orientation');
|
|
514
|
+
|
|
515
|
+
if (expectedPattern === 'vertical' && orientationAttr !== 'vertical') {
|
|
516
|
+
results.warnings.push(
|
|
517
|
+
'Container should have aria-orientation="vertical" for vertical navigation'
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (expectedPattern === 'horizontal' && orientationAttr === 'vertical') {
|
|
522
|
+
results.warnings.push(
|
|
523
|
+
'Container has aria-orientation="vertical" but horizontal navigation expected'
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Check grid pattern requirements
|
|
528
|
+
if (expectedPattern === 'grid') {
|
|
529
|
+
const gridRole = container.getAttribute('role');
|
|
530
|
+
if (gridRole !== 'grid' && gridRole !== 'treegrid') {
|
|
531
|
+
results.passed = false;
|
|
532
|
+
results.errors.push(
|
|
533
|
+
'Grid navigation pattern requires role="grid" or role="treegrid"'
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
results.passed = false;
|
|
539
|
+
results.errors.push(`Arrow navigation test error: ${error.message}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return results;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Comprehensive accessibility audit for interactive components
|
|
547
|
+
* @param {HTMLElement} component - Component to audit
|
|
548
|
+
* @param {Object} options - Audit options
|
|
549
|
+
* @returns {Object} Comprehensive audit results
|
|
550
|
+
*/
|
|
551
|
+
export const auditComponentAccessibility = (component, options = {}) => {
|
|
552
|
+
const {
|
|
553
|
+
expectedRole = null,
|
|
554
|
+
checkColorContrast = false,
|
|
555
|
+
checkFocusManagement = true,
|
|
556
|
+
checkARIACompliance = true,
|
|
557
|
+
} = options;
|
|
558
|
+
|
|
559
|
+
const audit = {
|
|
560
|
+
passed: true,
|
|
561
|
+
score: 100,
|
|
562
|
+
errors: [],
|
|
563
|
+
warnings: [],
|
|
564
|
+
recommendations: [],
|
|
565
|
+
tests: {},
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
// Basic keyboard navigation test
|
|
570
|
+
audit.tests.keyboardNavigation = testKeyboardNavigation(component, {
|
|
571
|
+
expectedFocusableCount: null,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (!audit.tests.keyboardNavigation.passed) {
|
|
575
|
+
audit.passed = false;
|
|
576
|
+
audit.score -= 30;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Check for roving tabindex patterns
|
|
580
|
+
const rovingElements = component.querySelectorAll(
|
|
581
|
+
'[role="tablist"], [role="menubar"], [role="toolbar"]'
|
|
582
|
+
);
|
|
583
|
+
if (rovingElements.length > 0) {
|
|
584
|
+
audit.tests.rovingTabindex = testRovingTabindex(rovingElements[0]);
|
|
585
|
+
if (!audit.tests.rovingTabindex.passed) {
|
|
586
|
+
audit.score -= 20;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ARIA compliance check
|
|
591
|
+
if (checkARIACompliance) {
|
|
592
|
+
const interactiveElements = component.querySelectorAll(
|
|
593
|
+
'button, [role="button"], [role="menuitem"], [role="tab"]'
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
interactiveElements.forEach((element, index) => {
|
|
597
|
+
const role =
|
|
598
|
+
element.getAttribute('role') || element.tagName.toLowerCase();
|
|
599
|
+
const requiredAttrs = A11Y_PATTERNS.REQUIRED_ARIA[role] || [];
|
|
600
|
+
|
|
601
|
+
requiredAttrs.forEach((attr) => {
|
|
602
|
+
if (!element.hasAttribute(attr)) {
|
|
603
|
+
audit.errors.push(
|
|
604
|
+
`Element ${index} missing required ${attr} attribute`
|
|
605
|
+
);
|
|
606
|
+
audit.passed = false;
|
|
607
|
+
audit.score -= 10;
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Focus management check
|
|
614
|
+
if (checkFocusManagement) {
|
|
615
|
+
const focusableElements = component.querySelectorAll(
|
|
616
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
if (focusableElements.length === 0) {
|
|
620
|
+
audit.warnings.push('Component has no focusable elements');
|
|
621
|
+
audit.score -= 5;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Expected role check
|
|
626
|
+
if (expectedRole) {
|
|
627
|
+
const actualRole = component.getAttribute('role');
|
|
628
|
+
if (actualRole !== expectedRole) {
|
|
629
|
+
audit.warnings.push(
|
|
630
|
+
`Expected role="${expectedRole}" but found "${actualRole || 'none'}"`
|
|
631
|
+
);
|
|
632
|
+
audit.score -= 10;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Generate recommendations based on findings
|
|
637
|
+
if (audit.score < 100) {
|
|
638
|
+
if (audit.errors.length > 0) {
|
|
639
|
+
audit.recommendations.push(
|
|
640
|
+
'Fix critical accessibility errors before release'
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (audit.warnings.length > 0) {
|
|
644
|
+
audit.recommendations.push(
|
|
645
|
+
'Address accessibility warnings to improve user experience'
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (audit.score < 70) {
|
|
649
|
+
audit.recommendations.push(
|
|
650
|
+
'Consider comprehensive accessibility review with disabled users'
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Ensure score doesn't go below 0
|
|
656
|
+
audit.score = Math.max(0, audit.score);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
audit.passed = false;
|
|
659
|
+
audit.score = 0;
|
|
660
|
+
audit.errors.push(`Audit error: ${error.message}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return audit;
|
|
664
|
+
};
|