@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,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
|
+
};
|