@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,334 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
// Context for tracking heading hierarchy across components
|
|
4
|
+
const HeadingHierarchyContext = createContext({
|
|
5
|
+
registeredHeadings: [],
|
|
6
|
+
registerHeading: () => {},
|
|
7
|
+
unregisterHeading: () => {},
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Provider component for heading hierarchy context
|
|
12
|
+
* Allows automatic tracking of heading levels across multiple components
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} props - Component props
|
|
15
|
+
* @param {React.ReactNode} props.children - Child components
|
|
16
|
+
* @returns {JSX.Element} Context provider
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <HeadingHierarchyProvider>
|
|
20
|
+
* <YourApp />
|
|
21
|
+
* </HeadingHierarchyProvider>
|
|
22
|
+
*/
|
|
23
|
+
export const HeadingHierarchyProvider = ({ children }) => {
|
|
24
|
+
const [registeredHeadings, setRegisteredHeadings] = useState([]);
|
|
25
|
+
|
|
26
|
+
const registerHeading = (id, level) => {
|
|
27
|
+
setRegisteredHeadings((prev) => {
|
|
28
|
+
const existing = prev.find((h) => h.id === id);
|
|
29
|
+
if (existing) {
|
|
30
|
+
return prev.map((h) => (h.id === id ? { ...h, level } : h));
|
|
31
|
+
}
|
|
32
|
+
return [...prev, { id, level, timestamp: Date.now() }];
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const unregisterHeading = (id) => {
|
|
37
|
+
setRegisteredHeadings((prev) => prev.filter((h) => h.id !== id));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const value = {
|
|
41
|
+
registeredHeadings,
|
|
42
|
+
registerHeading,
|
|
43
|
+
unregisterHeading,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<HeadingHierarchyContext.Provider value={value}>
|
|
48
|
+
{children}
|
|
49
|
+
</HeadingHierarchyContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Custom hook for enhanced heading accessibility features
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} options - Configuration options
|
|
57
|
+
* @param {number} options.level - Heading level (1-6)
|
|
58
|
+
* @param {boolean} options.autoFocus - Whether to auto-focus the heading on mount
|
|
59
|
+
* @param {boolean} options.announceOnChange - Whether to announce content changes to screen readers
|
|
60
|
+
* @param {Function} options.onFocus - Callback when heading receives focus
|
|
61
|
+
* @param {boolean} options.registerWithContext - Whether to register with HeadingHierarchyContext (default: true)
|
|
62
|
+
* @param {string} options.id - Unique identifier for context registration (auto-generated if not provided)
|
|
63
|
+
* @returns {Object} Ref and accessibility properties
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const { headingRef, ariaProps } = useHeadingAccessibility({
|
|
67
|
+
* level: 1,
|
|
68
|
+
* autoFocus: true,
|
|
69
|
+
* announceOnChange: true
|
|
70
|
+
* });
|
|
71
|
+
*/
|
|
72
|
+
export const useHeadingAccessibility = ({
|
|
73
|
+
level = 1,
|
|
74
|
+
autoFocus = false,
|
|
75
|
+
announceOnChange = false,
|
|
76
|
+
onFocus = null,
|
|
77
|
+
registerWithContext = true,
|
|
78
|
+
id = null,
|
|
79
|
+
} = {}) => {
|
|
80
|
+
const headingRef = useRef(null);
|
|
81
|
+
const previousContent = useRef('');
|
|
82
|
+
const headingId = useRef(
|
|
83
|
+
id || `heading-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Try to get context, but don't fail if not available
|
|
87
|
+
const hierarchyContext = useContext(HeadingHierarchyContext);
|
|
88
|
+
|
|
89
|
+
// Determine if this is the primary header (first h1)
|
|
90
|
+
let isPrimaryHeader = false;
|
|
91
|
+
if (level === 1) {
|
|
92
|
+
if (
|
|
93
|
+
hierarchyContext &&
|
|
94
|
+
Array.isArray(hierarchyContext.registeredHeadings) &&
|
|
95
|
+
hierarchyContext.registeredHeadings.length > 0
|
|
96
|
+
) {
|
|
97
|
+
// Find the first registered h1
|
|
98
|
+
const firstH1 = hierarchyContext.registeredHeadings.find(
|
|
99
|
+
(h) => h.level === 1
|
|
100
|
+
);
|
|
101
|
+
isPrimaryHeader = firstH1 && firstH1.id === headingId.current;
|
|
102
|
+
} else {
|
|
103
|
+
// If no context, assume this is the primary header
|
|
104
|
+
isPrimaryHeader = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (autoFocus && headingRef.current) {
|
|
110
|
+
// Focus the heading for immediate screen reader announcement
|
|
111
|
+
headingRef.current.focus();
|
|
112
|
+
}
|
|
113
|
+
}, [autoFocus]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
// Register with context if available and enabled
|
|
117
|
+
if (registerWithContext && hierarchyContext?.registerHeading) {
|
|
118
|
+
hierarchyContext.registerHeading(headingId.current, level);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Cleanup: unregister when component unmounts or level changes
|
|
122
|
+
return () => {
|
|
123
|
+
if (registerWithContext && hierarchyContext?.unregisterHeading) {
|
|
124
|
+
hierarchyContext.unregisterHeading(headingId.current);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}, [level, registerWithContext, hierarchyContext]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const heading = headingRef.current;
|
|
131
|
+
if (!heading || !announceOnChange) return;
|
|
132
|
+
|
|
133
|
+
const currentContent = heading.textContent || heading.innerText;
|
|
134
|
+
|
|
135
|
+
// Announce content changes to screen readers
|
|
136
|
+
if (
|
|
137
|
+
previousContent.current !== currentContent &&
|
|
138
|
+
previousContent.current !== ''
|
|
139
|
+
) {
|
|
140
|
+
const announcement = document.createElement('div');
|
|
141
|
+
announcement.setAttribute('aria-live', 'polite');
|
|
142
|
+
announcement.setAttribute('aria-atomic', 'true');
|
|
143
|
+
announcement.className = 'sr-only';
|
|
144
|
+
announcement.textContent = `Heading updated: ${currentContent}`;
|
|
145
|
+
|
|
146
|
+
document.body.appendChild(announcement);
|
|
147
|
+
|
|
148
|
+
// Clean up announcement after screen reader has time to process
|
|
149
|
+
setTimeout(() => {
|
|
150
|
+
if (document.body.contains(announcement)) {
|
|
151
|
+
document.body.removeChild(announcement);
|
|
152
|
+
}
|
|
153
|
+
}, 1000);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
previousContent.current = currentContent;
|
|
157
|
+
}, [announceOnChange, headingRef]);
|
|
158
|
+
|
|
159
|
+
const handleFocus = (event) => {
|
|
160
|
+
if (onFocus) {
|
|
161
|
+
onFocus(event);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const ariaProps = {
|
|
166
|
+
'aria-level': level,
|
|
167
|
+
tabIndex: autoFocus ? 0 : undefined,
|
|
168
|
+
onFocus: onFocus ? handleFocus : undefined,
|
|
169
|
+
id: headingId.current,
|
|
170
|
+
};
|
|
171
|
+
// Set role="banner" for the primary h1
|
|
172
|
+
if (level === 1 && isPrimaryHeader) {
|
|
173
|
+
ariaProps.role = 'banner';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
headingRef,
|
|
178
|
+
ariaProps,
|
|
179
|
+
headingId: headingId.current,
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Automatically detects existing heading levels in the DOM
|
|
185
|
+
* @returns {Array} Array of existing heading levels found in the document
|
|
186
|
+
*/
|
|
187
|
+
const detectExistingHeadingLevels = () => {
|
|
188
|
+
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
189
|
+
const levels = Array.from(headings).map((heading) => {
|
|
190
|
+
const tagName = heading.tagName.toLowerCase();
|
|
191
|
+
return parseInt(tagName.charAt(1), 10);
|
|
192
|
+
});
|
|
193
|
+
return [...new Set(levels)].sort((a, b) => a - b);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Hook to validate heading hierarchy and warn about accessibility issues
|
|
198
|
+
* Automatically detects existing heading levels in the DOM to eliminate manual tracking
|
|
199
|
+
*
|
|
200
|
+
* @param {number} level - Current heading level
|
|
201
|
+
* @param {Object} options - Configuration options
|
|
202
|
+
* @param {Array} options.existingLevels - Manual override for existing levels (optional)
|
|
203
|
+
* @param {boolean} options.autoDetect - Whether to automatically detect existing levels (default: true)
|
|
204
|
+
* @returns {Object} Validation results and warnings
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Automatic detection (recommended)
|
|
208
|
+
* const { isValidHierarchy, warnings } = useHeadingHierarchy(3);
|
|
209
|
+
*
|
|
210
|
+
* // Manual override if needed
|
|
211
|
+
* const { isValidHierarchy, warnings } = useHeadingHierarchy(3, { existingLevels: [1, 2] });
|
|
212
|
+
*/
|
|
213
|
+
/**
|
|
214
|
+
* Hook to validate heading hierarchy and warn about accessibility issues
|
|
215
|
+
* Automatically detects existing heading levels using context or DOM scanning
|
|
216
|
+
*
|
|
217
|
+
* @param {number} level - Current heading level
|
|
218
|
+
* @param {Object} options - Configuration options
|
|
219
|
+
* @param {Array} options.existingLevels - Manual override for existing levels (optional)
|
|
220
|
+
* @param {boolean} options.useContext - Whether to use HeadingHierarchyContext for detection (default: true)
|
|
221
|
+
* @param {boolean} options.autoDetect - Whether to automatically detect existing levels via DOM (default: true when context unavailable)
|
|
222
|
+
* @returns {Object} Validation results and warnings
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* // Automatic detection with context (recommended)
|
|
226
|
+
* const { isValidHierarchy, warnings, detectedLevels } = useHeadingHierarchy(3);
|
|
227
|
+
*
|
|
228
|
+
* // Manual override if needed
|
|
229
|
+
* const { isValidHierarchy, warnings } = useHeadingHierarchy(3, { existingLevels: [1, 2] });
|
|
230
|
+
*/
|
|
231
|
+
export const useHeadingHierarchy = (level, options = {}) => {
|
|
232
|
+
const {
|
|
233
|
+
existingLevels: manualLevels = null,
|
|
234
|
+
useContext: useContextDetection = true,
|
|
235
|
+
autoDetect = true,
|
|
236
|
+
} = options;
|
|
237
|
+
|
|
238
|
+
const hierarchyContext = useContext(HeadingHierarchyContext);
|
|
239
|
+
const [detectedLevels, setDetectedLevels] = useState([]);
|
|
240
|
+
const warnings = [];
|
|
241
|
+
let isValidHierarchy = true;
|
|
242
|
+
|
|
243
|
+
// Auto-detect existing heading levels when component mounts or when autoDetect is enabled
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (manualLevels !== null) return; // Skip detection if manual levels provided
|
|
246
|
+
|
|
247
|
+
if (useContextDetection && hierarchyContext?.registeredHeadings) {
|
|
248
|
+
// Use context-based detection (preferred)
|
|
249
|
+
const contextLevels = hierarchyContext.registeredHeadings
|
|
250
|
+
.map((h) => h.level)
|
|
251
|
+
.filter((l) => l !== level) // Exclude current level
|
|
252
|
+
.sort((a, b) => a - b);
|
|
253
|
+
setDetectedLevels([...new Set(contextLevels)]);
|
|
254
|
+
} else if (autoDetect) {
|
|
255
|
+
// Fall back to DOM scanning
|
|
256
|
+
const domLevels = detectExistingHeadingLevels().filter(
|
|
257
|
+
(l) => l !== level
|
|
258
|
+
); // Exclude current level to avoid self-detection
|
|
259
|
+
setDetectedLevels(domLevels);
|
|
260
|
+
}
|
|
261
|
+
}, [
|
|
262
|
+
level,
|
|
263
|
+
manualLevels,
|
|
264
|
+
useContextDetection,
|
|
265
|
+
autoDetect,
|
|
266
|
+
hierarchyContext?.registeredHeadings,
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
// Use manual levels if provided, otherwise use auto-detected levels
|
|
270
|
+
const existingLevels = manualLevels !== null ? manualLevels : detectedLevels;
|
|
271
|
+
|
|
272
|
+
if (existingLevels.length === 0 && level !== 1) {
|
|
273
|
+
warnings.push('Page should start with an h1 heading');
|
|
274
|
+
isValidHierarchy = false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (existingLevels.length > 0) {
|
|
278
|
+
const lastLevel = Math.max(...existingLevels);
|
|
279
|
+
const levelGap = level - lastLevel;
|
|
280
|
+
|
|
281
|
+
if (levelGap > 1) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`Heading level gap detected: h${lastLevel} → h${level}. Consider using h${
|
|
284
|
+
lastLevel + 1
|
|
285
|
+
} instead.`
|
|
286
|
+
);
|
|
287
|
+
isValidHierarchy = false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Log warnings in development
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (process.env.NODE_ENV === 'development' && warnings.length > 0) {
|
|
294
|
+
console.warn('Heading accessibility warnings:', warnings);
|
|
295
|
+
}
|
|
296
|
+
}, [warnings]);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
isValidHierarchy,
|
|
300
|
+
warnings,
|
|
301
|
+
detectedLevels: existingLevels,
|
|
302
|
+
detectionMethod:
|
|
303
|
+
manualLevels !== null
|
|
304
|
+
? 'manual'
|
|
305
|
+
: useContextDetection && hierarchyContext?.registeredHeadings
|
|
306
|
+
? 'context'
|
|
307
|
+
: 'dom',
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Hook to generate heading IDs for anchor links and table of contents
|
|
313
|
+
*
|
|
314
|
+
* @param {string} text - Heading text content
|
|
315
|
+
* @param {string} prefix - Optional prefix for the ID
|
|
316
|
+
* @returns {string} Generated heading ID
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* const headingId = useHeadingId('Getting Started Guide', 'section');
|
|
320
|
+
* // Returns: 'section-getting-started-guide'
|
|
321
|
+
*/
|
|
322
|
+
export const useHeadingId = (text, prefix = '') => {
|
|
323
|
+
if (!text) return '';
|
|
324
|
+
|
|
325
|
+
const baseId = text
|
|
326
|
+
.toString()
|
|
327
|
+
.toLowerCase()
|
|
328
|
+
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
|
329
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
330
|
+
.replace(/-+/g, '-') // Remove multiple consecutive hyphens
|
|
331
|
+
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
|
332
|
+
|
|
333
|
+
return prefix ? `${prefix}-${baseId}` : baseId;
|
|
334
|
+
};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom hook for managing radio group state with accessibility features
|
|
5
|
+
* Provides controlled state management for radio groups with validation
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} options - Configuration options
|
|
8
|
+
* @param {string} options.name - Name for the radio group (required)
|
|
9
|
+
* @param {string} options.defaultValue - Default selected value
|
|
10
|
+
* @param {boolean} options.required - Whether selection is required
|
|
11
|
+
* @param {Function} options.onChange - Change callback function
|
|
12
|
+
* @param {Function} options.onValidate - Custom validation function
|
|
13
|
+
* @returns {Object} Radio group state and handlers
|
|
14
|
+
*/
|
|
15
|
+
export const useRadioGroup = (options = {}) => {
|
|
16
|
+
const {
|
|
17
|
+
name,
|
|
18
|
+
defaultValue = '',
|
|
19
|
+
required = false,
|
|
20
|
+
onChange,
|
|
21
|
+
onValidate,
|
|
22
|
+
} = options;
|
|
23
|
+
|
|
24
|
+
const [selectedValue, setSelectedValue] = useState(defaultValue);
|
|
25
|
+
const [hasError, setHasError] = useState(false);
|
|
26
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
27
|
+
const [touched, setTouched] = useState(false);
|
|
28
|
+
|
|
29
|
+
// Handle radio selection change
|
|
30
|
+
const handleChange = useCallback(
|
|
31
|
+
(event) => {
|
|
32
|
+
const value = event.target.value;
|
|
33
|
+
setSelectedValue(value);
|
|
34
|
+
setTouched(true);
|
|
35
|
+
|
|
36
|
+
// Clear error when user makes a selection
|
|
37
|
+
if (hasError && value) {
|
|
38
|
+
setHasError(false);
|
|
39
|
+
setErrorMessage('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Call external onChange if provided
|
|
43
|
+
onChange?.(event, value);
|
|
44
|
+
},
|
|
45
|
+
[hasError, onChange]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Validate the current selection
|
|
49
|
+
const validate = useCallback(() => {
|
|
50
|
+
let isValid = true;
|
|
51
|
+
let message = '';
|
|
52
|
+
|
|
53
|
+
// Required field validation
|
|
54
|
+
if (required && !selectedValue) {
|
|
55
|
+
isValid = false;
|
|
56
|
+
message = 'Please select an option.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Custom validation
|
|
60
|
+
if (onValidate && selectedValue) {
|
|
61
|
+
const customValidation = onValidate(selectedValue);
|
|
62
|
+
if (customValidation !== true) {
|
|
63
|
+
isValid = false;
|
|
64
|
+
message = customValidation || 'Invalid selection.';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setHasError(!isValid);
|
|
69
|
+
setErrorMessage(message);
|
|
70
|
+
return isValid;
|
|
71
|
+
}, [required, selectedValue, onValidate]);
|
|
72
|
+
|
|
73
|
+
// Get props for individual radio inputs
|
|
74
|
+
const getRadioProps = useCallback(
|
|
75
|
+
(value) => ({
|
|
76
|
+
name,
|
|
77
|
+
value,
|
|
78
|
+
checked: selectedValue === value,
|
|
79
|
+
onChange: handleChange,
|
|
80
|
+
error: hasError,
|
|
81
|
+
errorText: hasError ? errorMessage : '',
|
|
82
|
+
'data-radio-group': name,
|
|
83
|
+
}),
|
|
84
|
+
[name, selectedValue, handleChange, hasError, errorMessage]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Reset the group state
|
|
88
|
+
const reset = useCallback(() => {
|
|
89
|
+
setSelectedValue(defaultValue);
|
|
90
|
+
setHasError(false);
|
|
91
|
+
setErrorMessage('');
|
|
92
|
+
setTouched(false);
|
|
93
|
+
}, [defaultValue]);
|
|
94
|
+
|
|
95
|
+
// Set value programmatically
|
|
96
|
+
const setValue = useCallback(
|
|
97
|
+
(value) => {
|
|
98
|
+
setSelectedValue(value);
|
|
99
|
+
setTouched(true);
|
|
100
|
+
if (hasError) {
|
|
101
|
+
setHasError(false);
|
|
102
|
+
setErrorMessage('');
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[hasError]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
// State
|
|
110
|
+
selectedValue,
|
|
111
|
+
hasError,
|
|
112
|
+
errorMessage,
|
|
113
|
+
touched,
|
|
114
|
+
isValid: !hasError,
|
|
115
|
+
|
|
116
|
+
// Handlers
|
|
117
|
+
handleChange,
|
|
118
|
+
validate,
|
|
119
|
+
reset,
|
|
120
|
+
setValue,
|
|
121
|
+
getRadioProps,
|
|
122
|
+
|
|
123
|
+
// Group props for fieldset
|
|
124
|
+
groupProps: {
|
|
125
|
+
name,
|
|
126
|
+
'data-radio-group': name,
|
|
127
|
+
'aria-invalid': hasError,
|
|
128
|
+
'aria-required': required,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Utility for managing keyboard navigation within radio groups
|
|
135
|
+
* Implements proper arrow key navigation as per ARIA standards
|
|
136
|
+
*
|
|
137
|
+
* @param {HTMLElement} groupElement - The radio group container
|
|
138
|
+
* @param {Object} options - Navigation options
|
|
139
|
+
* @param {Function} options.onChange - Optional callback when radio selection changes via keyboard
|
|
140
|
+
* @returns {Object} Navigation utilities
|
|
141
|
+
*/
|
|
142
|
+
export const useRadioKeyboardNavigation = (groupElement, options = {}) => {
|
|
143
|
+
const { onChange } = options;
|
|
144
|
+
const getRadioInputs = useCallback(() => {
|
|
145
|
+
if (!groupElement) return [];
|
|
146
|
+
return Array.from(
|
|
147
|
+
groupElement.querySelectorAll('input[type="radio"]:not(:disabled)')
|
|
148
|
+
);
|
|
149
|
+
}, [groupElement]);
|
|
150
|
+
|
|
151
|
+
const focusRadio = useCallback(
|
|
152
|
+
(radio) => {
|
|
153
|
+
if (radio) {
|
|
154
|
+
radio.focus();
|
|
155
|
+
radio.checked = true;
|
|
156
|
+
|
|
157
|
+
// Call onChange callback if provided
|
|
158
|
+
if (onChange) {
|
|
159
|
+
// Create a synthetic event-like object for consistency
|
|
160
|
+
const syntheticEvent = {
|
|
161
|
+
target: radio,
|
|
162
|
+
currentTarget: radio,
|
|
163
|
+
type: 'change',
|
|
164
|
+
};
|
|
165
|
+
onChange(syntheticEvent, radio.value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
[onChange]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const handleKeyDown = useCallback(
|
|
173
|
+
(event) => {
|
|
174
|
+
if (
|
|
175
|
+
!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
const radios = getRadioInputs();
|
|
182
|
+
if (radios.length === 0) return;
|
|
183
|
+
|
|
184
|
+
const currentIndex = radios.findIndex(
|
|
185
|
+
(radio) => radio === document.activeElement
|
|
186
|
+
);
|
|
187
|
+
let nextIndex;
|
|
188
|
+
|
|
189
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
190
|
+
nextIndex = currentIndex <= 0 ? radios.length - 1 : currentIndex - 1;
|
|
191
|
+
} else {
|
|
192
|
+
nextIndex = currentIndex >= radios.length - 1 ? 0 : currentIndex + 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
focusRadio(radios[nextIndex]);
|
|
196
|
+
},
|
|
197
|
+
[getRadioInputs, focusRadio]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
handleKeyDown,
|
|
202
|
+
focusFirst: useCallback(() => {
|
|
203
|
+
const radios = getRadioInputs();
|
|
204
|
+
const checkedRadio = radios.find((radio) => radio.checked);
|
|
205
|
+
focusRadio(checkedRadio || radios[0]);
|
|
206
|
+
}, [getRadioInputs, focusRadio]),
|
|
207
|
+
|
|
208
|
+
focusChecked: useCallback(() => {
|
|
209
|
+
const radios = getRadioInputs();
|
|
210
|
+
const checkedRadio = radios.find((radio) => radio.checked);
|
|
211
|
+
if (checkedRadio) checkedRadio.focus();
|
|
212
|
+
}, [getRadioInputs]),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Accessibility testing utility for radio groups
|
|
218
|
+
* Validates proper ARIA attributes and keyboard navigation
|
|
219
|
+
*
|
|
220
|
+
* @param {HTMLElement} groupElement - The radio group to test
|
|
221
|
+
* @returns {Object} Test results
|
|
222
|
+
*/
|
|
223
|
+
export const validateRadioGroupAccessibility = (groupElement) => {
|
|
224
|
+
const results = {
|
|
225
|
+
passed: true,
|
|
226
|
+
errors: [],
|
|
227
|
+
warnings: [],
|
|
228
|
+
info: [],
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (!groupElement) {
|
|
232
|
+
results.passed = false;
|
|
233
|
+
results.errors.push('No group element provided for testing');
|
|
234
|
+
return results;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check for fieldset/legend structure
|
|
238
|
+
const fieldset =
|
|
239
|
+
groupElement.closest('fieldset') || groupElement.querySelector('fieldset');
|
|
240
|
+
const legend = fieldset?.querySelector('legend');
|
|
241
|
+
|
|
242
|
+
if (!fieldset) {
|
|
243
|
+
results.warnings.push(
|
|
244
|
+
'Radio group should be wrapped in a <fieldset> element for better accessibility'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!legend) {
|
|
249
|
+
results.warnings.push(
|
|
250
|
+
'Radio group should have a <legend> element to describe the group'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check radio inputs
|
|
255
|
+
const radios = Array.from(
|
|
256
|
+
groupElement.querySelectorAll('input[type="radio"]')
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (radios.length === 0) {
|
|
260
|
+
results.passed = false;
|
|
261
|
+
results.errors.push('No radio inputs found in group');
|
|
262
|
+
return results;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check for proper name grouping
|
|
266
|
+
const names = [...new Set(radios.map((radio) => radio.name))];
|
|
267
|
+
if (names.length > 1) {
|
|
268
|
+
results.passed = false;
|
|
269
|
+
results.errors.push(
|
|
270
|
+
`All radios in group should have same name attribute. Found: ${names.join(
|
|
271
|
+
', '
|
|
272
|
+
)}`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check for labels
|
|
277
|
+
radios.forEach((radio, index) => {
|
|
278
|
+
const label =
|
|
279
|
+
radio.closest('label') ||
|
|
280
|
+
document.querySelector(`label[for="${radio.id}"]`);
|
|
281
|
+
if (!label) {
|
|
282
|
+
results.warnings.push(
|
|
283
|
+
`Radio ${index + 1} should have an associated label`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check for unique values
|
|
288
|
+
const duplicateValues = radios.filter(
|
|
289
|
+
(r) => r.value === radio.value && r !== radio
|
|
290
|
+
);
|
|
291
|
+
if (duplicateValues.length > 0) {
|
|
292
|
+
results.errors.push(
|
|
293
|
+
`Radio ${index + 1} has duplicate value "${radio.value}"`
|
|
294
|
+
);
|
|
295
|
+
results.passed = false;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Check for at least one enabled radio
|
|
300
|
+
const enabledRadios = radios.filter((radio) => !radio.disabled);
|
|
301
|
+
if (enabledRadios.length === 0) {
|
|
302
|
+
results.passed = false;
|
|
303
|
+
results.errors.push('At least one radio should be enabled');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
results.info.push(
|
|
307
|
+
`Found ${radios.length} radio inputs (${enabledRadios.length} enabled)`
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return results;
|
|
311
|
+
};
|