@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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +708 -0
  3. package/dist/__tests__/Anchor.test.js +145 -0
  4. package/dist/__tests__/ArrowRight.test.js +91 -0
  5. package/dist/__tests__/Avatar.test.js +123 -0
  6. package/dist/__tests__/Button.test.js +82 -0
  7. package/dist/__tests__/Card.test.js +198 -0
  8. package/dist/__tests__/CheckCircle.test.js +98 -0
  9. package/dist/__tests__/Checkbox.test.js +161 -0
  10. package/dist/__tests__/ChevronDown.test.js +73 -0
  11. package/dist/__tests__/Close.test.js +98 -0
  12. package/dist/__tests__/EditSquare.test.js +99 -0
  13. package/dist/__tests__/Error.test.js +74 -0
  14. package/dist/__tests__/Footer.test.js +66 -0
  15. package/dist/__tests__/Heading.test.js +227 -0
  16. package/dist/__tests__/Hero.test.js +74 -0
  17. package/dist/__tests__/Label.test.js +123 -0
  18. package/dist/__tests__/Loader.test.js +115 -0
  19. package/dist/__tests__/MenuHover.test.js +137 -0
  20. package/dist/__tests__/Paragraph.test.js +93 -0
  21. package/dist/__tests__/PlusCircle.test.js +99 -0
  22. package/dist/__tests__/Radio.test.js +153 -0
  23. package/dist/__tests__/Select.test.js +187 -0
  24. package/dist/__tests__/Tabs.test.js +162 -0
  25. package/dist/__tests__/TextArea.test.js +127 -0
  26. package/dist/__tests__/TextInput.test.js +181 -0
  27. package/dist/__tests__/Toggle.test.js +120 -0
  28. package/dist/__tests__/TrashX.test.js +99 -0
  29. package/dist/__tests__/useHeadingAccessibility.test.js +144 -0
  30. package/dist/components/Anchor.js +131 -0
  31. package/dist/components/Animation.js +129 -0
  32. package/dist/components/AnimationGroup.js +207 -0
  33. package/dist/components/AnimationToggle.js +216 -0
  34. package/dist/components/Avatar.js +153 -0
  35. package/dist/components/Button.js +218 -0
  36. package/dist/components/Card.js +222 -0
  37. package/dist/components/Checkbox.js +305 -0
  38. package/dist/components/Crud.js +564 -0
  39. package/dist/components/DragAndDrop.js +337 -0
  40. package/dist/components/Error.js +206 -0
  41. package/dist/components/Footer.js +99 -0
  42. package/dist/components/Form.js +412 -0
  43. package/dist/components/Header.js +372 -0
  44. package/dist/components/Heading.js +134 -0
  45. package/dist/components/Hero.js +181 -0
  46. package/dist/components/Label.js +256 -0
  47. package/dist/components/Loader.js +302 -0
  48. package/dist/components/MenuHover.js +114 -0
  49. package/dist/components/Paragraph.js +128 -0
  50. package/dist/components/Prompt.js +61 -0
  51. package/dist/components/Radio.js +254 -0
  52. package/dist/components/Select.js +422 -0
  53. package/dist/components/SideMenu.js +313 -0
  54. package/dist/components/Tabs.js +297 -0
  55. package/dist/components/TextArea.js +370 -0
  56. package/dist/components/TextInput.js +286 -0
  57. package/dist/components/Toggle.js +186 -0
  58. package/dist/components/crudFiles/CrudEditBase.js +150 -0
  59. package/dist/components/crudFiles/CrudViewBase.js +39 -0
  60. package/dist/components/crudFiles/crudDevelopment.js +118 -0
  61. package/dist/components/crudFiles/crudEditHandlers.js +50 -0
  62. package/dist/constants/animation.js +30 -0
  63. package/dist/icons/ArrowIcon.js +32 -0
  64. package/dist/icons/ArrowRight.js +33 -0
  65. package/dist/icons/CheckCircle.js +33 -0
  66. package/dist/icons/ChevronDown.js +28 -0
  67. package/dist/icons/Close.js +33 -0
  68. package/dist/icons/EditSquare.js +33 -0
  69. package/dist/icons/Ellipses.js +34 -0
  70. package/dist/icons/Hamburger.js +39 -0
  71. package/dist/icons/LoadingSpinner.js +42 -0
  72. package/dist/icons/PlusCircle.js +33 -0
  73. package/dist/icons/SaveIcon.js +32 -0
  74. package/dist/icons/TrashX.js +33 -0
  75. package/dist/icons/__tests__/CheckCircle.test.js +9 -0
  76. package/dist/icons/__tests__/ChevronDown.test.js +9 -0
  77. package/dist/icons/__tests__/Close.test.js +9 -0
  78. package/dist/icons/__tests__/EditSquare.test.js +9 -0
  79. package/dist/icons/__tests__/PlusCircle.test.js +9 -0
  80. package/dist/icons/__tests__/TrashX.test.js +9 -0
  81. package/dist/icons/index.js +89 -0
  82. package/dist/index.js +332 -0
  83. package/dist/setupTests.js +3 -0
  84. package/dist/styles/_variables.scss +286 -0
  85. package/dist/styles/anchor.scss +40 -0
  86. package/dist/styles/animation-accessibility.scss +96 -0
  87. package/dist/styles/animation-toggle.scss +233 -0
  88. package/dist/styles/animation.scss +3781 -0
  89. package/dist/styles/avatar.scss +285 -0
  90. package/dist/styles/button.scss +430 -0
  91. package/dist/styles/card.scss +210 -0
  92. package/dist/styles/checkbox.scss +160 -0
  93. package/dist/styles/crud.scss +474 -0
  94. package/dist/styles/dragAndDrop.scss +312 -0
  95. package/dist/styles/error.scss +232 -0
  96. package/dist/styles/footer.scss +58 -0
  97. package/dist/styles/form.scss +420 -0
  98. package/dist/styles/grid.scss +29 -0
  99. package/dist/styles/header.scss +276 -0
  100. package/dist/styles/heading.scss +118 -0
  101. package/dist/styles/hero.scss +185 -0
  102. package/dist/styles/htmlElements.scss +20 -0
  103. package/dist/styles/image.scss +9 -0
  104. package/dist/styles/label.scss +340 -0
  105. package/dist/styles/list-item.scss +5 -0
  106. package/dist/styles/loader.scss +354 -0
  107. package/dist/styles/logo.scss +19 -0
  108. package/dist/styles/main.css +9056 -0
  109. package/dist/styles/main.css.map +1 -0
  110. package/dist/styles/main.scss +0 -0
  111. package/dist/styles/menu-hover.scss +30 -0
  112. package/dist/styles/paragraph.scss +88 -0
  113. package/dist/styles/prompt.scss +51 -0
  114. package/dist/styles/radio.scss +202 -0
  115. package/dist/styles/select.scss +363 -0
  116. package/dist/styles/side-menu.scss +334 -0
  117. package/dist/styles/tabs.scss +540 -0
  118. package/dist/styles/text-area.scss +388 -0
  119. package/dist/styles/text-input.scss +171 -0
  120. package/dist/styles/toggle.scss +0 -0
  121. package/dist/styles/unordered-list.scss +8 -0
  122. package/dist/utils/ScrollHandler.js +30 -0
  123. package/dist/utils/accessibility.js +128 -0
  124. package/dist/utils/heroUtils.js +316 -0
  125. package/dist/utils/index.js +104 -0
  126. package/dist/utils/inputValidation.js +29 -0
  127. package/dist/utils/keyboardNavigation.js +536 -0
  128. package/dist/utils/labelUtils.js +708 -0
  129. package/dist/utils/loaderUtils.js +387 -0
  130. package/dist/utils/menuUtils.js +575 -0
  131. package/dist/utils/useHeadingAccessibility.js +298 -0
  132. package/dist/utils/useRadioGroup.js +260 -0
  133. package/dist/utils/useSelectAccessibility.js +426 -0
  134. package/dist/utils/useTabsAccessibility.js +278 -0
  135. package/dist/utils/useTextAreaAccessibility.js +255 -0
  136. package/dist/utils/useTextInputAccessibility.js +295 -0
  137. package/dist/utils/useTypographyAccessibility.js +168 -0
  138. package/dist/utils/useWindowSize.js +32 -0
  139. package/dist/utils/utils/ScrollHandler.js +26 -0
  140. package/dist/utils/utils/accessibility.js +133 -0
  141. package/dist/utils/utils/heroUtils.js +348 -0
  142. package/dist/utils/utils/index.js +9 -0
  143. package/dist/utils/utils/inputValidation.js +22 -0
  144. package/dist/utils/utils/keyboardNavigation.js +664 -0
  145. package/dist/utils/utils/labelUtils.js +772 -0
  146. package/dist/utils/utils/loaderUtils.js +436 -0
  147. package/dist/utils/utils/menuUtils.js +651 -0
  148. package/dist/utils/utils/useHeadingAccessibility.js +334 -0
  149. package/dist/utils/utils/useRadioGroup.js +311 -0
  150. package/dist/utils/utils/useSelectAccessibility.js +498 -0
  151. package/dist/utils/utils/useTabsAccessibility.js +316 -0
  152. package/dist/utils/utils/useTextAreaAccessibility.js +303 -0
  153. package/dist/utils/utils/useTextInputAccessibility.js +338 -0
  154. package/dist/utils/utils/useTypographyAccessibility.js +180 -0
  155. package/dist/utils/utils/useWindowSize.js +26 -0
  156. package/dist/utils/utils/validation.js +131 -0
  157. package/dist/utils/validation.js +139 -0
  158. 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
+ };