@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,316 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * Custom hook for managing tabs state with accessibility features
5
+ * Provides controlled state management for tab navigation with ARIA support
6
+ *
7
+ * @param {Object} options - Configuration options
8
+ * @param {Array} options.tabs - Array of tab objects with id, label, and content
9
+ * @param {string} options.defaultActiveTab - Default active tab ID
10
+ * @param {Function} options.onChange - Change callback function
11
+ * @returns {Object} Tabs state and handlers
12
+ */
13
+ export const useTabsAccessibility = (options = {}) => {
14
+ const { tabs = [], defaultActiveTab = '', onChange } = options;
15
+
16
+ const [activeTabId, setActiveTabId] = useState(
17
+ defaultActiveTab || tabs[0]?.id || ''
18
+ );
19
+ const [focusedTabIndex, setFocusedTabIndex] = useState(0);
20
+ const tabListRef = useRef(null);
21
+ const tabRefs = useRef({});
22
+
23
+ // Handle tab selection
24
+ const handleTabSelect = useCallback(
25
+ (tabId, index) => {
26
+ setActiveTabId(tabId);
27
+ setFocusedTabIndex(index);
28
+ onChange?.(tabId, index);
29
+ },
30
+ [onChange]
31
+ );
32
+
33
+ // Handle keyboard navigation
34
+ const handleKeyDown = useCallback(
35
+ (event, tabId, currentIndex) => {
36
+ let newIndex = currentIndex;
37
+
38
+ switch (event.key) {
39
+ case 'ArrowLeft':
40
+ event.preventDefault();
41
+ newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
42
+ break;
43
+ case 'ArrowRight':
44
+ event.preventDefault();
45
+ newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
46
+ break;
47
+ case 'Home':
48
+ event.preventDefault();
49
+ newIndex = 0;
50
+ break;
51
+ case 'End':
52
+ event.preventDefault();
53
+ newIndex = tabs.length - 1;
54
+ break;
55
+ case 'Enter':
56
+ case ' ':
57
+ event.preventDefault();
58
+ handleTabSelect(tabId, currentIndex);
59
+ return;
60
+ default:
61
+ return;
62
+ }
63
+
64
+ // Focus the new tab
65
+ const newTabId = tabs[newIndex]?.id;
66
+ if (newTabId && tabRefs.current[newTabId]) {
67
+ tabRefs.current[newTabId].focus();
68
+ setFocusedTabIndex(newIndex);
69
+ }
70
+ },
71
+ [tabs, handleTabSelect]
72
+ );
73
+
74
+ // Get props for tab list
75
+ const getTabListProps = useCallback(
76
+ () => ({
77
+ ref: tabListRef,
78
+ role: 'tablist',
79
+ 'aria-orientation': 'horizontal',
80
+ }),
81
+ []
82
+ );
83
+
84
+ // Get props for individual tabs
85
+ const getTabProps = useCallback(
86
+ (tab, index) => ({
87
+ ref: (el) => {
88
+ if (el) {
89
+ tabRefs.current[tab.id] = el;
90
+ }
91
+ },
92
+ id: `tab-${tab.id}`,
93
+ role: 'tab',
94
+ 'aria-selected': activeTabId === tab.id,
95
+ 'aria-controls': `panel-${tab.id}`,
96
+ tabIndex: activeTabId === tab.id ? 0 : -1,
97
+ onClick: () => handleTabSelect(tab.id, index),
98
+ onKeyDown: (event) => handleKeyDown(event, tab.id, index),
99
+ }),
100
+ [activeTabId, handleTabSelect, handleKeyDown]
101
+ );
102
+
103
+ // Get props for tab panels
104
+ const getTabPanelProps = useCallback(
105
+ (tab) => ({
106
+ id: `panel-${tab.id}`,
107
+ role: 'tabpanel',
108
+ 'aria-labelledby': `tab-${tab.id}`,
109
+ hidden: activeTabId !== tab.id,
110
+ tabIndex: activeTabId === tab.id ? 0 : -1,
111
+ }),
112
+ [activeTabId]
113
+ );
114
+
115
+ // Focus management - focus active tab on mount
116
+ useEffect(() => {
117
+ if (activeTabId && tabRefs.current[activeTabId]) {
118
+ const timer = setTimeout(() => {
119
+ tabRefs.current[activeTabId]?.focus();
120
+ }, 0);
121
+ return () => clearTimeout(timer);
122
+ }
123
+ }, []);
124
+
125
+ return {
126
+ // State
127
+ activeTabId,
128
+ focusedTabIndex,
129
+ tabListRef,
130
+ tabRefs,
131
+
132
+ // Handlers
133
+ handleTabSelect,
134
+ handleKeyDown,
135
+ setActiveTabId,
136
+
137
+ // Props getters
138
+ getTabListProps,
139
+ getTabProps,
140
+ getTabPanelProps,
141
+ };
142
+ };
143
+
144
+ /**
145
+ * Accessibility validation utility for tabs
146
+ * Validates proper ARIA attributes and keyboard navigation
147
+ *
148
+ * @param {HTMLElement} tabsElement - The tabs container element to test
149
+ * @returns {Object} Test results
150
+ */
151
+ export const validateTabsAccessibility = (tabsElement) => {
152
+ const results = {
153
+ passed: true,
154
+ errors: [],
155
+ warnings: [],
156
+ info: [],
157
+ };
158
+
159
+ if (!tabsElement) {
160
+ results.passed = false;
161
+ results.errors.push('No tabs element provided for testing');
162
+ return results;
163
+ }
164
+
165
+ // Check for tablist
166
+ const tabList = tabsElement.querySelector('[role="tablist"]');
167
+ if (!tabList) {
168
+ results.passed = false;
169
+ results.errors.push('Tabs must have a container with role="tablist"');
170
+ } else {
171
+ // Check for aria-orientation
172
+ if (!tabList.hasAttribute('aria-orientation')) {
173
+ results.warnings.push('Tablist should have aria-orientation attribute');
174
+ }
175
+
176
+ // Check tabs
177
+ const tabs = tabList.querySelectorAll('[role="tab"]');
178
+ if (tabs.length === 0) {
179
+ results.passed = false;
180
+ results.errors.push('Tablist must contain elements with role="tab"');
181
+ } else {
182
+ let hasSelectedTab = false;
183
+
184
+ tabs.forEach((tab, index) => {
185
+ // Check for aria-selected
186
+ if (!tab.hasAttribute('aria-selected')) {
187
+ results.warnings.push(
188
+ `Tab ${index + 1} should have aria-selected attribute`
189
+ );
190
+ } else if (tab.getAttribute('aria-selected') === 'true') {
191
+ hasSelectedTab = true;
192
+ }
193
+
194
+ // Check for aria-controls
195
+ if (!tab.hasAttribute('aria-controls')) {
196
+ results.warnings.push(
197
+ `Tab ${index + 1} should have aria-controls attribute`
198
+ );
199
+ }
200
+
201
+ // Check for proper tabindex
202
+ const tabIndex = tab.getAttribute('tabindex');
203
+ const isSelected = tab.getAttribute('aria-selected') === 'true';
204
+ if (isSelected && tabIndex !== '0') {
205
+ results.warnings.push(`Selected tab should have tabindex="0"`);
206
+ } else if (!isSelected && tabIndex !== '-1') {
207
+ results.warnings.push(`Unselected tabs should have tabindex="-1"`);
208
+ }
209
+ });
210
+
211
+ if (!hasSelectedTab) {
212
+ results.warnings.push('One tab should have aria-selected="true"');
213
+ }
214
+
215
+ results.info.push(`Found ${tabs.length} tabs`);
216
+ }
217
+
218
+ // Check panels
219
+ const panels = tabsElement.querySelectorAll('[role="tabpanel"]');
220
+ tabs.forEach((tab, index) => {
221
+ const controls = tab.getAttribute('aria-controls');
222
+ if (controls) {
223
+ const panel = tabsElement.querySelector(`#${controls}`);
224
+ if (!panel) {
225
+ results.warnings.push(
226
+ `Tab ${index + 1} aria-controls points to non-existent panel`
227
+ );
228
+ } else if (panel.getAttribute('role') !== 'tabpanel') {
229
+ results.warnings.push(
230
+ `Element referenced by tab ${index + 1} should have role="tabpanel"`
231
+ );
232
+ }
233
+ }
234
+ });
235
+
236
+ results.info.push(`Found ${panels.length} tab panels`);
237
+ }
238
+
239
+ return results;
240
+ };
241
+
242
+ /**
243
+ * Utility for enhancing tabs accessibility with live regions
244
+ * Provides screen reader announcements for tab changes
245
+ *
246
+ * @param {Array} tabs - Array of tab objects
247
+ * @param {string} activeTabId - Currently active tab ID
248
+ * @param {Object} options - Configuration options
249
+ * @returns {Object} Live region utilities
250
+ */
251
+ export const useTabsLiveRegion = (tabs, activeTabId, options = {}) => {
252
+ const { enableTabAnnouncement = true, customMessages = {} } = options;
253
+
254
+ const [liveRegion, setLiveRegion] = useState(null);
255
+
256
+ // Create live region for announcements
257
+ useEffect(() => {
258
+ const region = document.createElement('div');
259
+ region.setAttribute('aria-live', 'polite');
260
+ region.setAttribute('aria-atomic', 'true');
261
+ region.style.position = 'absolute';
262
+ region.style.left = '-10000px';
263
+ region.style.top = 'auto';
264
+ region.style.width = '1px';
265
+ region.style.height = '1px';
266
+ region.style.overflow = 'hidden';
267
+
268
+ document.body.appendChild(region);
269
+ setLiveRegion(region);
270
+
271
+ return () => {
272
+ if (document.body.contains(region)) {
273
+ document.body.removeChild(region);
274
+ }
275
+ };
276
+ }, []);
277
+
278
+ const announce = useCallback(
279
+ (message) => {
280
+ if (liveRegion) {
281
+ liveRegion.textContent = message;
282
+ }
283
+ },
284
+ [liveRegion]
285
+ );
286
+
287
+ const announceTabChange = useCallback(
288
+ (tabId) => {
289
+ if (!enableTabAnnouncement || !tabId) return;
290
+
291
+ const activeTab = tabs.find((tab) => tab.id === tabId);
292
+ if (activeTab) {
293
+ const message =
294
+ customMessages.tabChange ||
295
+ `${activeTab.label} tab selected. ${tabs.indexOf(activeTab) + 1} of ${
296
+ tabs.length
297
+ }`;
298
+ announce(message);
299
+ }
300
+ },
301
+ [announce, enableTabAnnouncement, customMessages.tabChange, tabs]
302
+ );
303
+
304
+ // Announce tab changes
305
+ useEffect(() => {
306
+ if (activeTabId) {
307
+ announceTabChange(activeTabId);
308
+ }
309
+ }, [activeTabId, announceTabChange]);
310
+
311
+ return {
312
+ announce,
313
+ announceTabChange,
314
+ liveRegion,
315
+ };
316
+ };
@@ -0,0 +1,303 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * Custom hook for managing textarea state with accessibility features
5
+ * Provides controlled state management for textarea with ARIA support
6
+ *
7
+ * @param {Object} options - Configuration options
8
+ * @param {string} options.defaultValue - Default textarea value
9
+ * @param {Function} options.onChange - Change callback function
10
+ * @param {boolean} options.required - Whether the field is required
11
+ * @param {number} options.maxLength - Maximum character count
12
+ * @param {number} options.minLength - Minimum character count
13
+ * @returns {Object} TextArea state and handlers
14
+ */
15
+ export const useTextAreaAccessibility = (options = {}) => {
16
+ const {
17
+ defaultValue = '',
18
+ onChange,
19
+ required = false,
20
+ maxLength,
21
+ minLength,
22
+ } = options;
23
+
24
+ const [value, setValue] = useState(defaultValue);
25
+ const [charCount, setCharCount] = useState(defaultValue.length);
26
+ const [isValid, setIsValid] = useState(!required || defaultValue.length > 0);
27
+ const [hasBeenTouched, setHasBeenTouched] = useState(false);
28
+ const textareaRef = useRef(null);
29
+
30
+ const handleChange = useCallback(
31
+ (event) => {
32
+ const newValue = event.target.value;
33
+ setValue(newValue);
34
+ setCharCount(newValue.length);
35
+ setHasBeenTouched(true);
36
+
37
+ // Validate the field
38
+ let valid = true;
39
+ if (required && newValue.trim().length === 0) {
40
+ valid = false;
41
+ }
42
+ if (minLength && newValue.length < minLength) {
43
+ valid = false;
44
+ }
45
+ if (maxLength && newValue.length > maxLength) {
46
+ valid = false;
47
+ }
48
+ setIsValid(valid);
49
+
50
+ // Call external onChange handler
51
+ onChange?.(event, {
52
+ value: newValue,
53
+ isValid: valid,
54
+ charCount: newValue.length,
55
+ });
56
+ },
57
+ [onChange, required, minLength, maxLength]
58
+ );
59
+
60
+ const handleFocus = useCallback((event) => {
61
+ // Focus handling for accessibility
62
+ setHasBeenTouched(true);
63
+ }, []);
64
+
65
+ const handleBlur = useCallback(
66
+ (event) => {
67
+ // Blur handling for validation
68
+ const newValue = event.target.value;
69
+ let valid = true;
70
+ if (required && newValue.trim().length === 0) {
71
+ valid = false;
72
+ }
73
+ if (minLength && newValue.length < minLength) {
74
+ valid = false;
75
+ }
76
+ if (maxLength && newValue.length > maxLength) {
77
+ valid = false;
78
+ }
79
+ setIsValid(valid);
80
+ },
81
+ [required, minLength, maxLength]
82
+ );
83
+
84
+ const reset = useCallback(() => {
85
+ setValue(defaultValue);
86
+ setCharCount(defaultValue.length);
87
+ setIsValid(!required || defaultValue.length > 0);
88
+ setHasBeenTouched(false);
89
+ }, [defaultValue, required]);
90
+
91
+ const focus = useCallback(() => {
92
+ textareaRef.current?.focus();
93
+ }, []);
94
+
95
+ return {
96
+ // State
97
+ value,
98
+ charCount,
99
+ isValid,
100
+ hasBeenTouched,
101
+ showError: hasBeenTouched && !isValid,
102
+
103
+ // Handlers
104
+ handleChange,
105
+ handleFocus,
106
+ handleBlur,
107
+ reset,
108
+ focus,
109
+
110
+ // Ref
111
+ textareaRef,
112
+
113
+ // Computed properties
114
+ remainingChars: maxLength ? maxLength - charCount : null,
115
+ isAtMaxLength: maxLength ? charCount >= maxLength : false,
116
+ isAtMinLength: minLength ? charCount >= minLength : true,
117
+ };
118
+ };
119
+
120
+ /**
121
+ * Validates textarea accessibility compliance
122
+ * Checks for proper ARIA attributes, labels, and keyboard support
123
+ *
124
+ * @param {HTMLElement} textareaElement - The textarea element to validate
125
+ * @returns {Object} Validation results with errors and warnings
126
+ */
127
+ export const validateTextAreaAccessibility = (textareaElement) => {
128
+ const results = {
129
+ passed: true,
130
+ errors: [],
131
+ warnings: [],
132
+ info: [],
133
+ };
134
+
135
+ if (!textareaElement) {
136
+ results.passed = false;
137
+ results.errors.push('No textarea element provided for validation');
138
+ return results;
139
+ }
140
+
141
+ // Check if it's actually a textarea element
142
+ if (textareaElement.tagName.toLowerCase() !== 'textarea') {
143
+ // Look for textarea within the element
144
+ const textarea = textareaElement.querySelector('textarea');
145
+ if (!textarea) {
146
+ results.passed = false;
147
+ results.errors.push(
148
+ 'Element is not a textarea and contains no textarea element'
149
+ );
150
+ return results;
151
+ }
152
+ // Use the found textarea for validation
153
+ textareaElement = textarea;
154
+ }
155
+
156
+ // 1. Check for accessible name (label, aria-label, or aria-labelledby)
157
+ const hasLabel =
158
+ textareaElement.hasAttribute('aria-label') ||
159
+ textareaElement.hasAttribute('aria-labelledby') ||
160
+ textareaElement.labels?.length > 0;
161
+
162
+ if (!hasLabel) {
163
+ results.passed = false;
164
+ results.errors.push(
165
+ 'Textarea must have an accessible name (label, aria-label, or aria-labelledby)'
166
+ );
167
+ }
168
+
169
+ // 2. Check for proper ID if labelledby is used
170
+ if (textareaElement.hasAttribute('aria-labelledby')) {
171
+ const labelId = textareaElement.getAttribute('aria-labelledby');
172
+ const labelElement = document.getElementById(labelId);
173
+ if (!labelElement) {
174
+ results.passed = false;
175
+ results.errors.push(
176
+ `Referenced label element with ID "${labelId}" not found`
177
+ );
178
+ }
179
+ }
180
+
181
+ // 3. Check for required field indication
182
+ if (
183
+ textareaElement.hasAttribute('required') ||
184
+ textareaElement.hasAttribute('aria-required')
185
+ ) {
186
+ if (!textareaElement.hasAttribute('aria-required')) {
187
+ results.warnings.push(
188
+ 'Consider adding aria-required="true" for screen reader users'
189
+ );
190
+ }
191
+ }
192
+
193
+ // 4. Check for error state accessibility
194
+ if (textareaElement.hasAttribute('aria-invalid')) {
195
+ const isInvalid = textareaElement.getAttribute('aria-invalid') === 'true';
196
+ if (isInvalid && !textareaElement.hasAttribute('aria-describedby')) {
197
+ results.warnings.push(
198
+ 'Invalid textarea should have aria-describedby pointing to error message'
199
+ );
200
+ }
201
+ }
202
+
203
+ // 5. Check for character count accessibility
204
+ const hasMaxLength = textareaElement.hasAttribute('maxlength');
205
+ if (hasMaxLength && !textareaElement.hasAttribute('aria-describedby')) {
206
+ results.warnings.push(
207
+ 'Textarea with maxlength should have aria-describedby for character count info'
208
+ );
209
+ }
210
+
211
+ // 6. Check for placeholder accessibility
212
+ if (textareaElement.hasAttribute('placeholder')) {
213
+ results.warnings.push(
214
+ 'Avoid using placeholder as the only label - ensure proper labeling exists'
215
+ );
216
+ }
217
+
218
+ // 7. Check for disabled state
219
+ if (textareaElement.disabled) {
220
+ results.info.push('Textarea is disabled');
221
+ }
222
+
223
+ // 8. Check for keyboard accessibility
224
+ const tabIndex = textareaElement.getAttribute('tabindex');
225
+ if (tabIndex && parseInt(tabIndex) < 0) {
226
+ results.warnings.push(
227
+ 'Textarea has negative tabindex - may not be keyboard accessible'
228
+ );
229
+ }
230
+
231
+ // 9. Check for resize capability
232
+ const computedStyle = window.getComputedStyle(textareaElement);
233
+ if (computedStyle.resize === 'none') {
234
+ results.info.push(
235
+ 'Textarea resize is disabled - consider allowing users to resize for better usability'
236
+ );
237
+ }
238
+
239
+ // 10. Check for minimum contrast (if possible)
240
+ const bgColor = computedStyle.backgroundColor;
241
+ const textColor = computedStyle.color;
242
+ if (bgColor && textColor) {
243
+ results.info.push(
244
+ 'Manual contrast check recommended for accessibility compliance'
245
+ );
246
+ }
247
+
248
+ if (results.errors.length === 0) {
249
+ results.info.push('Basic accessibility validation passed');
250
+ }
251
+
252
+ return results;
253
+ };
254
+
255
+ /**
256
+ * Character count utility for textarea accessibility
257
+ * Provides screen reader announcements for character limits
258
+ *
259
+ * @param {number} currentCount - Current character count
260
+ * @param {number} maxLength - Maximum allowed characters
261
+ * @param {number} warningThreshold - Threshold to start warnings (default: 0.8)
262
+ * @returns {Object} Character count info and announcements
263
+ */
264
+ export const getCharacterCountInfo = (
265
+ currentCount,
266
+ maxLength,
267
+ warningThreshold = 0.8
268
+ ) => {
269
+ if (!maxLength) return null;
270
+
271
+ const remaining = maxLength - currentCount;
272
+ const percentage = currentCount / maxLength;
273
+ const isWarning = percentage >= warningThreshold;
274
+ const isError = currentCount > maxLength;
275
+
276
+ let announcement = '';
277
+ let status = 'info';
278
+
279
+ if (isError) {
280
+ announcement = `Character limit exceeded. ${Math.abs(
281
+ remaining
282
+ )} characters over limit.`;
283
+ status = 'error';
284
+ } else if (isWarning) {
285
+ announcement = `${remaining} characters remaining.`;
286
+ status = 'warning';
287
+ } else {
288
+ announcement = `${currentCount} of ${maxLength} characters used.`;
289
+ status = 'info';
290
+ }
291
+
292
+ return {
293
+ currentCount,
294
+ maxLength,
295
+ remaining,
296
+ percentage,
297
+ isWarning,
298
+ isError,
299
+ announcement,
300
+ status,
301
+ ariaLabel: `Character count: ${announcement}`,
302
+ };
303
+ };