@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,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useTypographyAccessibility = exports.useFocusManagement = exports.getTypographyClassNames = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
/**
|
|
9
|
+
* Custom hook for typography accessibility features
|
|
10
|
+
* Manages user preferences for font size, contrast, and motion
|
|
11
|
+
*
|
|
12
|
+
* @returns {Object} Typography accessibility state and utilities
|
|
13
|
+
*/
|
|
14
|
+
const useTypographyAccessibility = () => {
|
|
15
|
+
const [preferences, setPreferences] = (0, _react.useState)({
|
|
16
|
+
highContrast: false,
|
|
17
|
+
reducedMotion: false,
|
|
18
|
+
fontSize: 100,
|
|
19
|
+
currentBreakpoint: 'small'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Detect user's system preferences
|
|
23
|
+
(0, _react.useEffect)(() => {
|
|
24
|
+
const detectSystemPreferences = () => {
|
|
25
|
+
setPreferences(prev => ({
|
|
26
|
+
...prev,
|
|
27
|
+
highContrast: window.matchMedia('(prefers-contrast: high)').matches || false,
|
|
28
|
+
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
detectSystemPreferences();
|
|
32
|
+
|
|
33
|
+
// Listen for changes in system preferences
|
|
34
|
+
const contrastQuery = window.matchMedia('(prefers-contrast: high)');
|
|
35
|
+
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
36
|
+
contrastQuery.addEventListener('change', detectSystemPreferences);
|
|
37
|
+
motionQuery.addEventListener('change', detectSystemPreferences);
|
|
38
|
+
return () => {
|
|
39
|
+
contrastQuery.removeEventListener('change', detectSystemPreferences);
|
|
40
|
+
motionQuery.removeEventListener('change', detectSystemPreferences);
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
// Detect current breakpoint
|
|
45
|
+
(0, _react.useEffect)(() => {
|
|
46
|
+
const updateBreakpoint = () => {
|
|
47
|
+
const width = window.innerWidth;
|
|
48
|
+
let breakpoint;
|
|
49
|
+
if (width >= 1200) breakpoint = 'xlarge';else if (width >= 992) breakpoint = 'large';else if (width >= 768) breakpoint = 'medium';else breakpoint = 'small';
|
|
50
|
+
setPreferences(prev => ({
|
|
51
|
+
...prev,
|
|
52
|
+
currentBreakpoint: breakpoint
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
updateBreakpoint();
|
|
56
|
+
window.addEventListener('resize', updateBreakpoint);
|
|
57
|
+
return () => window.removeEventListener('resize', updateBreakpoint);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
// Utility functions
|
|
61
|
+
const toggleHighContrast = () => {
|
|
62
|
+
setPreferences(prev => ({
|
|
63
|
+
...prev,
|
|
64
|
+
highContrast: !prev.highContrast
|
|
65
|
+
}));
|
|
66
|
+
};
|
|
67
|
+
const setFontSize = size => {
|
|
68
|
+
setPreferences(prev => ({
|
|
69
|
+
...prev,
|
|
70
|
+
fontSize: Math.max(50, Math.min(200, size))
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
73
|
+
const getAccessibilityStyles = () => {
|
|
74
|
+
return {
|
|
75
|
+
fontSize: `${preferences.fontSize}%`,
|
|
76
|
+
filter: preferences.highContrast ? 'contrast(150%) brightness(120%)' : 'none',
|
|
77
|
+
transition: preferences.reducedMotion ? 'none' : undefined
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const getBreakpointLabel = () => {
|
|
81
|
+
const labels = {
|
|
82
|
+
small: 'Small screens (<768px)',
|
|
83
|
+
medium: 'Medium (768px+)',
|
|
84
|
+
large: 'Large (992px+)',
|
|
85
|
+
xlarge: 'Extra Large (1200px+)'
|
|
86
|
+
};
|
|
87
|
+
return labels[preferences.currentBreakpoint];
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
preferences,
|
|
91
|
+
toggleHighContrast,
|
|
92
|
+
setFontSize,
|
|
93
|
+
getAccessibilityStyles,
|
|
94
|
+
getBreakpointLabel
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Utility for generating typography class names with accessibility considerations
|
|
100
|
+
*
|
|
101
|
+
* @param {string} baseClass - Base class name
|
|
102
|
+
* @param {string} size - Size variant (small, medium, large)
|
|
103
|
+
* @param {string} additionalClass - Additional class names
|
|
104
|
+
* @param {Object} preferences - Accessibility preferences
|
|
105
|
+
* @returns {string} Combined class names
|
|
106
|
+
*/
|
|
107
|
+
exports.useTypographyAccessibility = useTypographyAccessibility;
|
|
108
|
+
const getTypographyClassNames = (baseClass, size = 'medium', additionalClass = '', preferences = {}) => {
|
|
109
|
+
const classes = [baseClass];
|
|
110
|
+
|
|
111
|
+
// Add size modifier
|
|
112
|
+
if (size && size !== 'medium') {
|
|
113
|
+
classes.push(`${baseClass}--${size}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Add accessibility modifiers
|
|
117
|
+
if (preferences.highContrast) {
|
|
118
|
+
classes.push(`${baseClass}--high-contrast`);
|
|
119
|
+
}
|
|
120
|
+
if (preferences.reducedMotion) {
|
|
121
|
+
classes.push(`${baseClass}--reduced-motion`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add additional classes
|
|
125
|
+
if (additionalClass) {
|
|
126
|
+
classes.push(additionalClass);
|
|
127
|
+
}
|
|
128
|
+
return classes.join(' ');
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Focus management utility for typography components
|
|
133
|
+
* Ensures proper focus indicators and keyboard navigation
|
|
134
|
+
*
|
|
135
|
+
* @param {React.RefObject} elementRef - Reference to the element
|
|
136
|
+
* @param {Object} options - Focus options
|
|
137
|
+
*/
|
|
138
|
+
exports.getTypographyClassNames = getTypographyClassNames;
|
|
139
|
+
const useFocusManagement = (elementRef, options = {}) => {
|
|
140
|
+
const {
|
|
141
|
+
onFocus,
|
|
142
|
+
onBlur,
|
|
143
|
+
focusOnMount = false,
|
|
144
|
+
trapFocus = false
|
|
145
|
+
} = options;
|
|
146
|
+
(0, _react.useEffect)(() => {
|
|
147
|
+
const element = elementRef.current;
|
|
148
|
+
if (!element) return;
|
|
149
|
+
if (focusOnMount) {
|
|
150
|
+
element.focus();
|
|
151
|
+
}
|
|
152
|
+
const handleFocus = event => {
|
|
153
|
+
element.setAttribute('data-focused', 'true');
|
|
154
|
+
onFocus?.(event);
|
|
155
|
+
};
|
|
156
|
+
const handleBlur = event => {
|
|
157
|
+
element.removeAttribute('data-focused');
|
|
158
|
+
onBlur?.(event);
|
|
159
|
+
};
|
|
160
|
+
element.addEventListener('focus', handleFocus);
|
|
161
|
+
element.addEventListener('blur', handleBlur);
|
|
162
|
+
return () => {
|
|
163
|
+
element.removeEventListener('focus', handleFocus);
|
|
164
|
+
element.removeEventListener('blur', handleBlur);
|
|
165
|
+
};
|
|
166
|
+
}, [elementRef, onFocus, onBlur, focusOnMount, trapFocus]);
|
|
167
|
+
};
|
|
168
|
+
exports.useFocusManagement = useFocusManagement;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useWindowSize = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
const useWindowSize = () => {
|
|
9
|
+
// Initialize state with undefined width/height so server and client renders match
|
|
10
|
+
const [windowSize, setWindowSize] = (0, _react.useState)({
|
|
11
|
+
width: undefined,
|
|
12
|
+
height: undefined
|
|
13
|
+
});
|
|
14
|
+
(0, _react.useEffect)(() => {
|
|
15
|
+
// Handler to call on window resize
|
|
16
|
+
function handleResize() {
|
|
17
|
+
// Set window width/height to state
|
|
18
|
+
setWindowSize({
|
|
19
|
+
width: window.innerWidth,
|
|
20
|
+
height: window.innerHeight
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// Add event listener
|
|
24
|
+
window.addEventListener('resize', handleResize);
|
|
25
|
+
// Call handler right away so state gets updated with initial window size
|
|
26
|
+
handleResize();
|
|
27
|
+
// Remove event listener on cleanup
|
|
28
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
29
|
+
}, []); // Empty array ensures that effect is only run on mount
|
|
30
|
+
return windowSize;
|
|
31
|
+
};
|
|
32
|
+
exports.useWindowSize = useWindowSize;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { withRouter } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
const ScrollHandler = ({ location }) => {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const element = document.getElementById(location.hash);
|
|
8
|
+
|
|
9
|
+
setTimeout(() => {
|
|
10
|
+
window.scrollTo({
|
|
11
|
+
behavior: element ? 'smooth' : 'auto',
|
|
12
|
+
top: element ? element.offsetTop : 0,
|
|
13
|
+
});
|
|
14
|
+
}, 100);
|
|
15
|
+
}, [location]);
|
|
16
|
+
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
ScrollHandler.propTypes = {
|
|
21
|
+
location: PropTypes.shape({
|
|
22
|
+
hash: PropTypes.string,
|
|
23
|
+
}).isRequired,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default withRouter(ScrollHandler);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility utility functions for screen readers and ARIA support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Announces a message to screen readers using ARIA live regions
|
|
7
|
+
* This is a safer alternative to direct DOM manipulation
|
|
8
|
+
*
|
|
9
|
+
* @param {string} message - The message to announce
|
|
10
|
+
* @param {'polite'|'assertive'} priority - The announcement priority level
|
|
11
|
+
* @param {number} timeout - How long to keep the announcement (default: 1000ms)
|
|
12
|
+
* @returns {Function} Cleanup function to remove the announcement early
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Announce an error assertively
|
|
16
|
+
* const cleanup = announceToScreenReader('Form validation failed', 'assertive');
|
|
17
|
+
*
|
|
18
|
+
* // Announce info politely
|
|
19
|
+
* announceToScreenReader('Data saved automatically', 'polite');
|
|
20
|
+
*/
|
|
21
|
+
export const announceToScreenReader = (
|
|
22
|
+
message,
|
|
23
|
+
priority = 'polite',
|
|
24
|
+
timeout = 1000
|
|
25
|
+
) => {
|
|
26
|
+
// Ensure we're in a browser environment
|
|
27
|
+
if (typeof document === 'undefined') {
|
|
28
|
+
return () => {}; // Return no-op cleanup function for SSR
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const announcement = document.createElement('div');
|
|
32
|
+
announcement.setAttribute('aria-live', priority);
|
|
33
|
+
announcement.setAttribute('aria-atomic', 'true');
|
|
34
|
+
announcement.className = 'sr-only'; // Use CSS class instead of inline styles
|
|
35
|
+
announcement.textContent = message;
|
|
36
|
+
|
|
37
|
+
// Add to document body
|
|
38
|
+
document.body.appendChild(announcement);
|
|
39
|
+
|
|
40
|
+
// Set up cleanup
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
if (document.body.contains(announcement)) {
|
|
43
|
+
document.body.removeChild(announcement);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Auto cleanup after timeout
|
|
48
|
+
setTimeout(cleanup, timeout);
|
|
49
|
+
|
|
50
|
+
// Return cleanup function for manual cleanup if needed
|
|
51
|
+
return cleanup;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a live region element that can be reused for announcements
|
|
56
|
+
* This is more efficient for multiple announcements
|
|
57
|
+
*
|
|
58
|
+
* @param {'polite'|'assertive'} priority - The announcement priority level
|
|
59
|
+
* @returns {Object} Object with announce and destroy methods
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const liveRegion = createLiveRegion('assertive');
|
|
63
|
+
* liveRegion.announce('First message');
|
|
64
|
+
* liveRegion.announce('Second message');
|
|
65
|
+
* liveRegion.destroy(); // Clean up when done
|
|
66
|
+
*/
|
|
67
|
+
export const createLiveRegion = (priority = 'polite') => {
|
|
68
|
+
// Ensure we're in a browser environment
|
|
69
|
+
if (typeof document === 'undefined') {
|
|
70
|
+
return {
|
|
71
|
+
announce: () => {},
|
|
72
|
+
destroy: () => {},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const liveRegion = document.createElement('div');
|
|
77
|
+
liveRegion.setAttribute('aria-live', priority);
|
|
78
|
+
liveRegion.setAttribute('aria-atomic', 'true');
|
|
79
|
+
liveRegion.className = 'sr-only';
|
|
80
|
+
liveRegion.setAttribute(
|
|
81
|
+
'role',
|
|
82
|
+
priority === 'assertive' ? 'alert' : 'status'
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
document.body.appendChild(liveRegion);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
/**
|
|
89
|
+
* Announce a message in this live region
|
|
90
|
+
* @param {string} message - The message to announce
|
|
91
|
+
*/
|
|
92
|
+
announce: (message) => {
|
|
93
|
+
liveRegion.textContent = message;
|
|
94
|
+
// Clear after a short delay to allow for new announcements
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
if (liveRegion.textContent === message) {
|
|
97
|
+
liveRegion.textContent = '';
|
|
98
|
+
}
|
|
99
|
+
}, 1000);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clean up the live region
|
|
104
|
+
*/
|
|
105
|
+
destroy: () => {
|
|
106
|
+
if (document.body.contains(liveRegion)) {
|
|
107
|
+
document.body.removeChild(liveRegion);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Utility to focus an element safely with fallback
|
|
115
|
+
* @param {HTMLElement|string} element - Element or selector to focus
|
|
116
|
+
* @param {Object} options - Focus options
|
|
117
|
+
* @returns {boolean} Whether focus was successful
|
|
118
|
+
*/
|
|
119
|
+
export const safeFocus = (element, options = {}) => {
|
|
120
|
+
try {
|
|
121
|
+
const el =
|
|
122
|
+
typeof element === 'string' ? document.querySelector(element) : element;
|
|
123
|
+
|
|
124
|
+
if (el && typeof el.focus === 'function') {
|
|
125
|
+
el.focus(options);
|
|
126
|
+
return document.activeElement === el;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.warn('Failed to focus element:', error);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hero Component Utilities
|
|
3
|
+
* Provides helper functions and hooks for Hero component functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook to detect user's motion preference and apply appropriate settings
|
|
10
|
+
* @returns {Object} Motion preference settings
|
|
11
|
+
*/
|
|
12
|
+
export const useMotionPreference = () => {
|
|
13
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (typeof window !== 'undefined') {
|
|
17
|
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
18
|
+
setPrefersReducedMotion(mediaQuery.matches);
|
|
19
|
+
|
|
20
|
+
const handleChange = (e) => {
|
|
21
|
+
setPrefersReducedMotion(e.matches);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
25
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
26
|
+
}
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
prefersReducedMotion,
|
|
31
|
+
backgroundAttachment: prefersReducedMotion ? 'scroll' : 'fixed',
|
|
32
|
+
transitionDuration: prefersReducedMotion ? '0s' : '0.3s',
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hook to detect high contrast mode preference
|
|
38
|
+
* @returns {boolean} Whether high contrast mode is enabled
|
|
39
|
+
*/
|
|
40
|
+
export const useHighContrast = () => {
|
|
41
|
+
const [highContrast, setHighContrast] = useState(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (typeof window !== 'undefined') {
|
|
45
|
+
const mediaQuery = window.matchMedia('(prefers-contrast: high)');
|
|
46
|
+
setHighContrast(mediaQuery.matches);
|
|
47
|
+
|
|
48
|
+
const handleChange = (e) => {
|
|
49
|
+
setHighContrast(e.matches);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
53
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return highContrast;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Hook for responsive hero height based on viewport
|
|
62
|
+
* @param {Object} options - Configuration options
|
|
63
|
+
* @param {string} options.minHeight - Minimum height (default: '400px')
|
|
64
|
+
* @param {string} options.maxHeight - Maximum height (default: '100vh')
|
|
65
|
+
* @returns {Object} Height settings and viewport info
|
|
66
|
+
*/
|
|
67
|
+
export const useResponsiveHeight = ({
|
|
68
|
+
minHeight = '400px',
|
|
69
|
+
maxHeight = '100vh',
|
|
70
|
+
} = {}) => {
|
|
71
|
+
const [viewportHeight, setViewportHeight] = useState(
|
|
72
|
+
typeof window !== 'undefined' ? window.innerHeight : 800
|
|
73
|
+
);
|
|
74
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const updateHeight = () => {
|
|
78
|
+
setViewportHeight(window.innerHeight);
|
|
79
|
+
setIsMobile(window.innerWidth < 768);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
updateHeight();
|
|
83
|
+
window.addEventListener('resize', updateHeight);
|
|
84
|
+
return () => window.removeEventListener('resize', updateHeight);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const calculatedHeight = Math.max(
|
|
88
|
+
parseInt(minHeight),
|
|
89
|
+
Math.min(viewportHeight * 0.7, parseInt(maxHeight))
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
height: `${calculatedHeight}px`,
|
|
94
|
+
viewportHeight,
|
|
95
|
+
isMobile,
|
|
96
|
+
isLandscape: viewportHeight < window.innerWidth,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook for managing background image loading and optimization
|
|
102
|
+
* @param {string} imageUrl - URL of the background image
|
|
103
|
+
* @returns {Object} Image loading state and optimized URL
|
|
104
|
+
*/
|
|
105
|
+
export const useBackgroundImage = (imageUrl) => {
|
|
106
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
107
|
+
const [hasError, setHasError] = useState(false);
|
|
108
|
+
const [optimizedUrl, setOptimizedUrl] = useState(imageUrl);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!imageUrl) return;
|
|
112
|
+
|
|
113
|
+
setIsLoaded(false);
|
|
114
|
+
setHasError(false);
|
|
115
|
+
|
|
116
|
+
const img = new Image();
|
|
117
|
+
img.onload = () => setIsLoaded(true);
|
|
118
|
+
img.onerror = () => setHasError(true);
|
|
119
|
+
img.src = imageUrl;
|
|
120
|
+
|
|
121
|
+
// Basic URL optimization for common image services
|
|
122
|
+
if (imageUrl.includes('unsplash.com')) {
|
|
123
|
+
const size =
|
|
124
|
+
window.innerWidth > 1200
|
|
125
|
+
? '2070'
|
|
126
|
+
: window.innerWidth > 768
|
|
127
|
+
? '1536'
|
|
128
|
+
: '1024';
|
|
129
|
+
setOptimizedUrl(`${imageUrl}&w=${size}&auto=format&fit=crop&q=80`);
|
|
130
|
+
} else {
|
|
131
|
+
setOptimizedUrl(imageUrl);
|
|
132
|
+
}
|
|
133
|
+
}, [imageUrl]);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
isLoaded,
|
|
137
|
+
hasError,
|
|
138
|
+
optimizedUrl,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generates accessible hero props based on content and configuration
|
|
144
|
+
* @param {Object} config - Configuration object
|
|
145
|
+
* @returns {Object} Accessibility props
|
|
146
|
+
*/
|
|
147
|
+
export const generateHeroAccessibility = ({
|
|
148
|
+
hasHeading = true,
|
|
149
|
+
hasCallToAction = false,
|
|
150
|
+
isLandingPage = false,
|
|
151
|
+
customRole = null,
|
|
152
|
+
customLabel = null,
|
|
153
|
+
}) => {
|
|
154
|
+
const role = customRole || (isLandingPage ? 'banner' : 'region');
|
|
155
|
+
|
|
156
|
+
const ariaLabel =
|
|
157
|
+
customLabel ||
|
|
158
|
+
(hasCallToAction
|
|
159
|
+
? 'Hero section with call-to-action'
|
|
160
|
+
: 'Hero content section');
|
|
161
|
+
|
|
162
|
+
const props = {
|
|
163
|
+
role,
|
|
164
|
+
'aria-label': ariaLabel,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Add landmark navigation hints
|
|
168
|
+
if (hasHeading && !customLabel) {
|
|
169
|
+
delete props['aria-label']; // Let heading provide the label
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return props;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Validates hero content for accessibility compliance
|
|
177
|
+
* @param {Object} content - Content configuration
|
|
178
|
+
* @returns {Array} Array of accessibility warnings
|
|
179
|
+
*/
|
|
180
|
+
export const validateHeroAccessibility = ({
|
|
181
|
+
hasBackground = false,
|
|
182
|
+
backgroundAlt = '',
|
|
183
|
+
hasHeading = false,
|
|
184
|
+
headingLevel = null,
|
|
185
|
+
hasCallToAction = false,
|
|
186
|
+
textContrast = 'unknown',
|
|
187
|
+
}) => {
|
|
188
|
+
const warnings = [];
|
|
189
|
+
|
|
190
|
+
// Background image validation
|
|
191
|
+
if (hasBackground && !backgroundAlt) {
|
|
192
|
+
warnings.push({
|
|
193
|
+
type: 'error',
|
|
194
|
+
message: 'Background images must have alt text for accessibility',
|
|
195
|
+
recommendation: 'Add backgroundAlt prop with descriptive text',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Heading validation
|
|
200
|
+
if (!hasHeading) {
|
|
201
|
+
warnings.push({
|
|
202
|
+
type: 'warning',
|
|
203
|
+
message: 'Hero sections should typically include a heading',
|
|
204
|
+
recommendation: 'Add an h1 or h2 element to provide section context',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (hasHeading && headingLevel && headingLevel > 2) {
|
|
209
|
+
warnings.push({
|
|
210
|
+
type: 'warning',
|
|
211
|
+
message: `Hero heading level h${headingLevel} may be too deep`,
|
|
212
|
+
recommendation: 'Consider using h1 or h2 for hero sections',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Call-to-action validation
|
|
217
|
+
if (hasCallToAction) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
type: 'info',
|
|
220
|
+
message: 'Ensure CTA buttons have descriptive aria-label attributes',
|
|
221
|
+
recommendation:
|
|
222
|
+
'Use aria-label to describe the action, not just "Click here"',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Contrast validation
|
|
227
|
+
if (hasBackground && textContrast === 'low') {
|
|
228
|
+
warnings.push({
|
|
229
|
+
type: 'error',
|
|
230
|
+
message: 'Text contrast may be insufficient over background image',
|
|
231
|
+
recommendation:
|
|
232
|
+
'Add overlay or adjust text colors to meet WCAG contrast requirements',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return warnings;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Hero testing utilities for automated and manual testing
|
|
241
|
+
*/
|
|
242
|
+
export const heroTestingUtils = {
|
|
243
|
+
/**
|
|
244
|
+
* Simulates keyboard navigation through hero content
|
|
245
|
+
*/
|
|
246
|
+
async simulateKeyboardNavigation() {
|
|
247
|
+
const focusableElements = document.querySelectorAll(
|
|
248
|
+
'[role="banner"] a, [role="banner"] button, [role="banner"] input, [role="banner"] [tabindex="0"]'
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const results = [];
|
|
252
|
+
|
|
253
|
+
for (let element of focusableElements) {
|
|
254
|
+
element.focus();
|
|
255
|
+
const isVisible = this.isFocusVisible(element);
|
|
256
|
+
const hasAriaLabel =
|
|
257
|
+
element.getAttribute('aria-label') ||
|
|
258
|
+
element.getAttribute('aria-labelledby');
|
|
259
|
+
|
|
260
|
+
results.push({
|
|
261
|
+
element: element.tagName,
|
|
262
|
+
isFocusVisible: isVisible,
|
|
263
|
+
hasAriaLabel: !!hasAriaLabel,
|
|
264
|
+
textContent: element.textContent?.trim().substring(0, 50),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Small delay to simulate real user interaction
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return results;
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Checks if focus indicators are visible
|
|
276
|
+
*/
|
|
277
|
+
isFocusVisible(element) {
|
|
278
|
+
const styles = window.getComputedStyle(element, ':focus-visible');
|
|
279
|
+
return styles.outlineWidth !== '0px' || styles.boxShadow !== 'none';
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validates screen reader announcements
|
|
284
|
+
*/
|
|
285
|
+
validateScreenReaderContent() {
|
|
286
|
+
const hero = document.querySelector('[role="banner"], [role="region"]');
|
|
287
|
+
if (!hero) return { valid: false, message: 'No hero section found' };
|
|
288
|
+
|
|
289
|
+
const hasAriaLabel =
|
|
290
|
+
hero.getAttribute('aria-label') || hero.getAttribute('aria-labelledby');
|
|
291
|
+
const hasHeading = hero.querySelector('h1, h2, h3, h4, h5, h6');
|
|
292
|
+
const backgroundDescription = hero.querySelector('[id*="bg-description"]');
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
valid: hasAriaLabel || hasHeading,
|
|
296
|
+
hasAriaLabel: !!hasAriaLabel,
|
|
297
|
+
hasHeading: !!hasHeading,
|
|
298
|
+
hasBackgroundDescription: !!backgroundDescription,
|
|
299
|
+
recommendations: [
|
|
300
|
+
...(!(hasAriaLabel || hasHeading)
|
|
301
|
+
? ['Add aria-label or heading for screen readers']
|
|
302
|
+
: []),
|
|
303
|
+
...(!backgroundDescription && hero.style.backgroundImage
|
|
304
|
+
? ['Add background image description']
|
|
305
|
+
: []),
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Tests responsive behavior
|
|
312
|
+
*/
|
|
313
|
+
testResponsiveBehavior() {
|
|
314
|
+
const viewports = [
|
|
315
|
+
{ width: 320, height: 568, name: 'Mobile Portrait' },
|
|
316
|
+
{ width: 768, height: 1024, name: 'Tablet' },
|
|
317
|
+
{ width: 1200, height: 800, name: 'Desktop' },
|
|
318
|
+
{ width: 1920, height: 1080, name: 'Large Desktop' },
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const hero = document.querySelector('.hero-wrapper');
|
|
322
|
+
if (!hero) return { valid: false, message: 'Hero wrapper not found' };
|
|
323
|
+
|
|
324
|
+
const results = viewports.map((viewport) => {
|
|
325
|
+
// Note: In real testing, you'd actually resize the viewport
|
|
326
|
+
// This is a simulation for documentation purposes
|
|
327
|
+
return {
|
|
328
|
+
viewport: viewport.name,
|
|
329
|
+
dimensions: `${viewport.width}x${viewport.height}`,
|
|
330
|
+
minHeight: window.getComputedStyle(hero).minHeight,
|
|
331
|
+
backgroundAttachment:
|
|
332
|
+
window.getComputedStyle(hero).backgroundAttachment,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return { valid: true, results };
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export default {
|
|
341
|
+
useMotionPreference,
|
|
342
|
+
useHighContrast,
|
|
343
|
+
useResponsiveHeight,
|
|
344
|
+
useBackgroundImage,
|
|
345
|
+
generateHeroAccessibility,
|
|
346
|
+
validateHeroAccessibility,
|
|
347
|
+
heroTestingUtils,
|
|
348
|
+
};
|