@umituz/web-design-system 3.1.9 → 3.1.11
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/package.json +1 -1
- package/src/infrastructure/performance/index.ts +72 -2
- package/src/infrastructure/security/validation.ts +41 -37
- package/src/infrastructure/utils/cn.util.ts +48 -11
- package/src/presentation/atoms/Checkbox.tsx +6 -5
- package/src/presentation/atoms/Input.tsx +4 -4
- package/src/presentation/atoms/Switch.tsx +3 -3
- package/src/presentation/hooks/useClickOutside.ts +3 -1
- package/src/presentation/hooks/useDebounce.ts +4 -4
- package/src/presentation/hooks/useKeyboard.ts +37 -44
- package/src/presentation/hooks/useLocalStorage.ts +2 -1
- package/src/presentation/hooks/useMediaQuery.ts +24 -12
- package/src/presentation/hooks/useScrollLock.ts +2 -2
- package/src/presentation/hooks/useTheme.ts +60 -15
- package/src/presentation/organisms/Modal.tsx +16 -5
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
|
-
export { usePerformanceMonitor } from './usePerformanceMonitor';
|
|
1
|
+
export { usePerformanceMonitor, useRenderPerformance } from './usePerformanceMonitor';
|
|
2
2
|
export { useLazyLoading } from './useLazyLoading';
|
|
3
|
-
export { useMemoryOptimization } from './useMemoryOptimization';
|
|
3
|
+
export { useMemoryOptimization, useMemoryLeakDetector } from './useMemoryOptimization';
|
|
4
4
|
|
|
5
5
|
export type { PerformanceMetrics, PerformanceConfig } from './usePerformanceMonitor';
|
|
6
6
|
export type { MemoryOptimizationConfig, CleanupFunction } from './useMemoryOptimization';
|
|
7
|
+
|
|
8
|
+
// Performance monitoring utilities
|
|
9
|
+
export const performanceUtils = {
|
|
10
|
+
// Measure render performance
|
|
11
|
+
measureRender: (componentName: string, fn: () => void) => {
|
|
12
|
+
const start = performance.now();
|
|
13
|
+
fn();
|
|
14
|
+
const end = performance.now();
|
|
15
|
+
|
|
16
|
+
if (import.meta.env.DEV) {
|
|
17
|
+
const duration = end - start;
|
|
18
|
+
if (duration > 16) {
|
|
19
|
+
console.warn(`[Performance] ${componentName} took ${duration.toFixed(2)}ms to render`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return end - start;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Measure async operation
|
|
27
|
+
measureAsync: async <T>(componentName: string, operation: string, fn: () => Promise<T>): Promise<T> => {
|
|
28
|
+
const start = performance.now();
|
|
29
|
+
try {
|
|
30
|
+
const result = await fn();
|
|
31
|
+
const duration = performance.now() - start;
|
|
32
|
+
|
|
33
|
+
if (import.meta.env.DEV) {
|
|
34
|
+
console.log(`[Performance] ${componentName} ${operation} completed in ${duration.toFixed(2)}ms`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const duration = performance.now() - start;
|
|
40
|
+
console.error(`[Performance] ${componentName} ${operation} failed after ${duration.toFixed(2)}ms:`, error);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// Get performance metrics
|
|
46
|
+
getMetrics: () => {
|
|
47
|
+
if (typeof performance === 'undefined' || !performance.memory) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
memory: {
|
|
53
|
+
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
|
54
|
+
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
|
55
|
+
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
|
56
|
+
},
|
|
57
|
+
navigation: performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Log performance summary
|
|
62
|
+
logSummary: () => {
|
|
63
|
+
const metrics = performanceUtils.getMetrics();
|
|
64
|
+
if (!metrics) return;
|
|
65
|
+
|
|
66
|
+
if (import.meta.env.DEV && metrics.memory) {
|
|
67
|
+
const memoryUsage = ((metrics.memory.usedJSHeapSize / metrics.memory.jsHeapSizeLimit) * 100).toFixed(2);
|
|
68
|
+
console.log(`[Performance] Memory usage: ${memoryUsage}%`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (metrics.navigation) {
|
|
72
|
+
const loadTime = metrics.navigation.loadEventEnd - metrics.navigation.fetchStart;
|
|
73
|
+
console.log(`[Performance] Page load time: ${loadTime.toFixed(2)}ms`);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -38,22 +38,22 @@ export const validateInput = (
|
|
|
38
38
|
|
|
39
39
|
// HTML tag check for non-HTML content
|
|
40
40
|
if (!config?.allowHtml) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/
|
|
48
|
-
/
|
|
49
|
-
/
|
|
50
|
-
/
|
|
51
|
-
|
|
41
|
+
// FIX: Better HTML detection that catches encoded variations
|
|
42
|
+
const htmlPatterns = [
|
|
43
|
+
/<[^>]*>/gi, // Standard HTML tags
|
|
44
|
+
/<[^&]*>/gi, // HTML encoded tags
|
|
45
|
+
/<[a-z]/gi, // Partial encoded tags
|
|
46
|
+
/javascript:/gi, // JavaScript protocol
|
|
47
|
+
/vbscript:/gi, // VBScript protocol
|
|
48
|
+
/data:text\/html/gi, // Data URI with HTML
|
|
49
|
+
/on\w+\s*=/gi, // Inline event handlers
|
|
50
|
+
/<script/gi, // Script tags (case-insensitive)
|
|
51
|
+
/<\/script>/gi, // Closing script tags
|
|
52
52
|
];
|
|
53
53
|
|
|
54
|
-
for (const pattern of
|
|
54
|
+
for (const pattern of htmlPatterns) {
|
|
55
55
|
if (pattern.test(trimmed)) {
|
|
56
|
-
return { isValid: false, error: "
|
|
56
|
+
return { isValid: false, error: "HTML tags or scripts are not allowed" };
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -180,19 +180,20 @@ export const validateFileName = (fileName: string): ValidationResult => {
|
|
|
180
180
|
return { isValid: false, error: "File name is required" };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// FIX: Remove global flag from regex patterns to prevent lastIndex issues
|
|
184
|
+
// Each pattern is tested once and should not have global flag
|
|
184
185
|
const dangerousPatterns = [
|
|
185
|
-
|
|
186
|
-
/[<>:"|?*]
|
|
187
|
-
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
/\s
|
|
186
|
+
{ pattern: /\.\./, name: 'Directory traversal' },
|
|
187
|
+
{ pattern: /[<>:"|?*]/, name: 'Invalid characters' },
|
|
188
|
+
{ pattern: /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i, name: 'Windows reserved' },
|
|
189
|
+
{ pattern: /^\./, name: 'Hidden file' },
|
|
190
|
+
{ pattern: /\.$/, name: 'Trailing dot' },
|
|
191
|
+
{ pattern: /\s+$/, name: 'Trailing whitespace' }
|
|
191
192
|
];
|
|
192
193
|
|
|
193
|
-
for (const pattern of dangerousPatterns) {
|
|
194
|
+
for (const { pattern, name } of dangerousPatterns) {
|
|
194
195
|
if (pattern.test(trimmed)) {
|
|
195
|
-
return { isValid: false, error:
|
|
196
|
+
return { isValid: false, error: `Invalid file name: ${name}` };
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -211,24 +212,27 @@ export const validateFileName = (fileName: string): ValidationResult => {
|
|
|
211
212
|
export const validateCSPCompliance = (content: string): ValidationResult => {
|
|
212
213
|
const violations = [];
|
|
213
214
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
215
|
+
// FIX: Enhanced script detection - catch more variations
|
|
216
|
+
const scriptPatterns = [
|
|
217
|
+
/<script[^>]*>/gi,
|
|
218
|
+
/<script/gi,
|
|
219
|
+
/<\/script>/gi,
|
|
220
|
+
/javascript:/gi,
|
|
221
|
+
/vbscript:/gi,
|
|
222
|
+
/data:\s*text\/html/gi,
|
|
223
|
+
/data:\s*application\/javascript/gi
|
|
224
|
+
];
|
|
223
225
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
for (const pattern of scriptPatterns) {
|
|
227
|
+
if (pattern.test(content)) {
|
|
228
|
+
violations.push("Potentially dangerous script content detected");
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
227
231
|
}
|
|
228
232
|
|
|
229
|
-
// Check for
|
|
230
|
-
if (
|
|
231
|
-
violations.push("
|
|
233
|
+
// Check for inline styles (more lenient - styles are often used legitimately)
|
|
234
|
+
if (/<style[^>]*>[\s\S]*?<\/style>/gi.test(content)) {
|
|
235
|
+
violations.push("Inline style blocks not allowed");
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
if (violations.length > 0) {
|
|
@@ -36,7 +36,10 @@ class LRUCache<K, V> {
|
|
|
36
36
|
// Remove oldest entry if at capacity
|
|
37
37
|
else if (this.cache.size >= this.maxSize) {
|
|
38
38
|
const firstKey = this.cache.keys().next().value;
|
|
39
|
-
|
|
39
|
+
// FIX: Check if firstKey exists before deleting
|
|
40
|
+
if (firstKey !== undefined) {
|
|
41
|
+
this.cache.delete(firstKey);
|
|
42
|
+
}
|
|
40
43
|
}
|
|
41
44
|
this.cache.set(key, value);
|
|
42
45
|
}
|
|
@@ -49,23 +52,57 @@ class LRUCache<K, V> {
|
|
|
49
52
|
// Create cache instance
|
|
50
53
|
const classNameCache = new LRUCache<string, string>(256);
|
|
51
54
|
|
|
52
|
-
//
|
|
55
|
+
// FIX: Better cache key generator that handles functions and undefined
|
|
53
56
|
function generateCacheKey(inputs: ClassValue[]): string {
|
|
54
|
-
|
|
57
|
+
try {
|
|
58
|
+
// Convert inputs to a cacheable string representation
|
|
59
|
+
const normalized = inputs.map((input) => {
|
|
60
|
+
if (input === null || input === undefined) {
|
|
61
|
+
return 'null';
|
|
62
|
+
}
|
|
63
|
+
if (typeof input === 'function') {
|
|
64
|
+
// For functions, use a placeholder since functions can't be reliably serialized
|
|
65
|
+
return 'function';
|
|
66
|
+
}
|
|
67
|
+
if (typeof input === 'object') {
|
|
68
|
+
// Handle objects (like conditional objects in clsx)
|
|
69
|
+
return JSON.stringify(
|
|
70
|
+
Object.entries(input)
|
|
71
|
+
.filter(([_, value]) => Boolean(value))
|
|
72
|
+
.map(([key]) => key)
|
|
73
|
+
.sort()
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return String(input);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return normalized.join('|');
|
|
80
|
+
} catch {
|
|
81
|
+
// Fallback if anything goes wrong with serialization
|
|
82
|
+
return inputs.join('|');
|
|
83
|
+
}
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
export function cn(...inputs: ClassValue[]): string {
|
|
58
|
-
|
|
59
|
-
const
|
|
87
|
+
// Skip cache for dynamic inputs that might contain functions
|
|
88
|
+
const hasFunction = inputs.some((input) => typeof input === 'function');
|
|
60
89
|
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
if (!hasFunction) {
|
|
91
|
+
const cacheKey = generateCacheKey(inputs);
|
|
92
|
+
const cached = classNameCache.get(cacheKey);
|
|
64
93
|
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
if (cached) {
|
|
95
|
+
return cached;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = twMerge(clsx(inputs));
|
|
99
|
+
classNameCache.set(cacheKey, result);
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
// For inputs with functions, skip cache and compute directly
|
|
105
|
+
return twMerge(clsx(inputs));
|
|
69
106
|
}
|
|
70
107
|
|
|
71
108
|
// Export cache for manual clearing if needed
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Checkbox input element
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { forwardRef, type InputHTMLAttributes } from 'react';
|
|
6
|
+
import { forwardRef, type InputHTMLAttributes, useCallback } from 'react';
|
|
7
7
|
import { cn } from '../../infrastructure/utils';
|
|
8
8
|
import type { BaseProps } from '../../domain/types';
|
|
9
9
|
|
|
@@ -20,11 +20,12 @@ const sizeStyles = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|
23
|
-
({ className, checked, onCheckedChange, size = 'md', disabled, ...props }, ref) => {
|
|
24
|
-
|
|
23
|
+
({ className, checked, onCheckedChange, size = 'md', disabled, onChange, ...props }, ref) => {
|
|
24
|
+
// FIX: Memoize handleChange to prevent re-renders
|
|
25
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
25
26
|
onCheckedChange?.(e.target.checked);
|
|
26
|
-
|
|
27
|
-
};
|
|
27
|
+
onChange?.(e);
|
|
28
|
+
}, [onCheckedChange, onChange]);
|
|
28
29
|
|
|
29
30
|
return (
|
|
30
31
|
<input
|
|
@@ -14,9 +14,9 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const sizeStyles: Record<MediumSizes, string> = {
|
|
17
|
-
sm: 'h-8
|
|
18
|
-
md: 'h-9
|
|
19
|
-
lg: 'h-10
|
|
17
|
+
sm: 'h-8 text-sm',
|
|
18
|
+
md: 'h-9 text-sm',
|
|
19
|
+
lg: 'h-10 text-base',
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
@@ -26,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
26
26
|
ref={ref}
|
|
27
27
|
type={type}
|
|
28
28
|
className={cn(
|
|
29
|
-
'flex w-full rounded-md border bg-background
|
|
29
|
+
'flex w-full rounded-md border bg-background py-2',
|
|
30
30
|
'text-sm ring-offset-background',
|
|
31
31
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
|
32
32
|
'placeholder:text-muted-foreground',
|
|
@@ -10,7 +10,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|
|
10
10
|
import { cn } from '../../infrastructure/utils';
|
|
11
11
|
|
|
12
12
|
const switchVariants = cva(
|
|
13
|
-
'peer inline-flex
|
|
13
|
+
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
|
14
14
|
{
|
|
15
15
|
variants: {
|
|
16
16
|
size: {
|
|
@@ -43,13 +43,13 @@ const switchThumbVariants = cva(
|
|
|
43
43
|
|
|
44
44
|
export interface SwitchProps extends React.ComponentProps<typeof SwitchPrimitives.Root>, VariantProps<typeof switchVariants> {}
|
|
45
45
|
|
|
46
|
-
const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
|
46
|
+
const Switch = React.memo(React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
|
47
47
|
({ className, size, ...props }, ref) => (
|
|
48
48
|
<SwitchPrimitives.Root className={cn(switchVariants({ size, className }))} {...props} ref={ref}>
|
|
49
49
|
<SwitchPrimitives.Thumb className={cn(switchThumbVariants({ size }))} />
|
|
50
50
|
</SwitchPrimitives.Root>
|
|
51
51
|
)
|
|
52
|
-
);
|
|
52
|
+
));
|
|
53
53
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
|
54
54
|
|
|
55
55
|
export { Switch };
|
|
@@ -15,7 +15,9 @@ export function useClickOutside<T extends HTMLElement>(
|
|
|
15
15
|
if (!enabled) return;
|
|
16
16
|
|
|
17
17
|
const handleClick = (event: Event) => {
|
|
18
|
-
|
|
18
|
+
// FIX: Safe null check and type guard
|
|
19
|
+
const target = event.target as Node;
|
|
20
|
+
if (ref.current && target && !ref.current.contains(target)) {
|
|
19
21
|
callback();
|
|
20
22
|
}
|
|
21
23
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Debounce a value with optimized performance
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useEffect, useRef } from 'react';
|
|
6
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
7
|
|
|
8
8
|
export function useDebounce<T>(value: T, delay: number = 500): T {
|
|
9
9
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
@@ -55,11 +55,11 @@ export function useThrottle<T extends (...args: any[]) => any>(
|
|
|
55
55
|
clearTimeout(timeoutRef.current);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// FIX: Ensure delay is never negative
|
|
59
|
+
const delayUntilNextExecution = Math.max(0, delay - timeSinceLastRun);
|
|
58
60
|
timeoutRef.current = window.setTimeout(() => {
|
|
59
61
|
lastRun.current = new Date();
|
|
60
62
|
func(...args);
|
|
61
|
-
},
|
|
63
|
+
}, delayUntilNextExecution);
|
|
62
64
|
}, [func, delay]) as T;
|
|
63
65
|
}
|
|
64
|
-
|
|
65
|
-
import { useCallback } from 'react';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Keyboard event handling
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useEffect } from 'react';
|
|
6
|
+
import { useEffect, useCallback } from 'react';
|
|
7
7
|
|
|
8
8
|
export type KeyboardKey = string;
|
|
9
9
|
export type KeyboardModifier = 'ctrl' | 'shift' | 'alt' | 'meta';
|
|
@@ -16,6 +16,24 @@ export interface KeyboardOptions {
|
|
|
16
16
|
enabled?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Helper function to check if modifiers match
|
|
20
|
+
const checkModifiers = (event: KeyboardEvent, modifiers: KeyboardModifier[]): boolean => {
|
|
21
|
+
return modifiers.every((mod) => {
|
|
22
|
+
switch (mod) {
|
|
23
|
+
case 'ctrl':
|
|
24
|
+
return event.ctrlKey;
|
|
25
|
+
case 'shift':
|
|
26
|
+
return event.shiftKey;
|
|
27
|
+
case 'alt':
|
|
28
|
+
return event.altKey;
|
|
29
|
+
case 'meta':
|
|
30
|
+
return event.metaKey;
|
|
31
|
+
default:
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
19
37
|
export function useKeyboard({
|
|
20
38
|
key,
|
|
21
39
|
modifiers = [],
|
|
@@ -23,52 +41,27 @@ export function useKeyboard({
|
|
|
23
41
|
onKeyUp,
|
|
24
42
|
enabled = true,
|
|
25
43
|
}: KeyboardOptions) {
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
// FIX: Create handlers once and reuse to prevent duplicate logic
|
|
45
|
+
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
46
|
+
const keyMatches = event.key.toLowerCase() === key.toLowerCase();
|
|
47
|
+
const modifiersMatch = checkModifiers(event, modifiers);
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
case 'ctrl':
|
|
34
|
-
return event.ctrlKey;
|
|
35
|
-
case 'shift':
|
|
36
|
-
return event.shiftKey;
|
|
37
|
-
case 'alt':
|
|
38
|
-
return event.altKey;
|
|
39
|
-
case 'meta':
|
|
40
|
-
return event.metaKey;
|
|
41
|
-
default:
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
});
|
|
49
|
+
if (keyMatches && modifiersMatch) {
|
|
50
|
+
onKeyDown?.(event);
|
|
51
|
+
}
|
|
52
|
+
}, [key, modifiers, onKeyDown]);
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
54
|
+
const handleKeyUp = useCallback((event: KeyboardEvent) => {
|
|
55
|
+
const keyMatches = event.key.toLowerCase() === key.toLowerCase();
|
|
56
|
+
const modifiersMatch = checkModifiers(event, modifiers);
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
case 'ctrl':
|
|
56
|
-
return event.ctrlKey;
|
|
57
|
-
case 'shift':
|
|
58
|
-
return event.shiftKey;
|
|
59
|
-
case 'alt':
|
|
60
|
-
return event.altKey;
|
|
61
|
-
case 'meta':
|
|
62
|
-
return event.metaKey;
|
|
63
|
-
default:
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
});
|
|
58
|
+
if (keyMatches && modifiersMatch) {
|
|
59
|
+
onKeyUp?.(event);
|
|
60
|
+
}
|
|
61
|
+
}, [key, modifiers, onKeyUp]);
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
};
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) return;
|
|
72
65
|
|
|
73
66
|
document.addEventListener('keydown', handleKeyDown);
|
|
74
67
|
document.addEventListener('keyup', handleKeyUp);
|
|
@@ -77,7 +70,7 @@ export function useKeyboard({
|
|
|
77
70
|
document.removeEventListener('keydown', handleKeyDown);
|
|
78
71
|
document.removeEventListener('keyup', handleKeyUp);
|
|
79
72
|
};
|
|
80
|
-
}, [
|
|
73
|
+
}, [enabled, handleKeyDown, handleKeyUp]);
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
export function useEscape(callback: () => void, enabled = true) {
|
|
@@ -25,7 +25,8 @@ export function useLocalStorage<T>(
|
|
|
25
25
|
const setValue = useCallback(
|
|
26
26
|
(value: T | ((prev: T) => T)) => {
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// FIX: Use typeof instead of instanceof for better React functional update support
|
|
29
|
+
const valueToStore = typeof value === 'function' ? (value as (prev: T) => T)(valueRef.current) : value;
|
|
29
30
|
setStoredValue(valueToStore);
|
|
30
31
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
31
32
|
} catch (error) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Enhanced responsive breakpoint detection with helper functions
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useEffect, useState, useCallback } from 'react';
|
|
6
|
+
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
7
7
|
import type {
|
|
8
8
|
Breakpoint,
|
|
9
9
|
UseBreakpointReturn,
|
|
@@ -29,6 +29,9 @@ export function useMediaQuery(breakpoint: Breakpoint): boolean {
|
|
|
29
29
|
const [matches, setMatches] = useState(false);
|
|
30
30
|
|
|
31
31
|
useEffect(() => {
|
|
32
|
+
// FIX: SSR-safe check
|
|
33
|
+
if (typeof window === 'undefined') return;
|
|
34
|
+
|
|
32
35
|
const query = createMediaQuery(breakpoint);
|
|
33
36
|
const media = window.matchMedia(query);
|
|
34
37
|
setMatches(media.matches);
|
|
@@ -58,24 +61,30 @@ export function useMediaQuery(breakpoint: Breakpoint): boolean {
|
|
|
58
61
|
* ```
|
|
59
62
|
*/
|
|
60
63
|
export function useBreakpoint(): UseBreakpointReturn {
|
|
64
|
+
// FIX: Sort breakpoints once and memoize
|
|
65
|
+
const sortedBreakpoints = useMemo(() => {
|
|
66
|
+
return Object.entries(BREAKPOINTS).sort(
|
|
67
|
+
([, a], [, b]) => b.min - a.min
|
|
68
|
+
);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
61
71
|
const [currentBreakpoint, setCurrentBreakpoint] = useState<Breakpoint>(() => {
|
|
62
|
-
//
|
|
72
|
+
// FIX: SSR-safe initialization
|
|
63
73
|
if (typeof window === 'undefined') return 'lg';
|
|
64
74
|
|
|
65
75
|
const width = window.innerWidth;
|
|
66
|
-
for (const [bp, value] of
|
|
76
|
+
for (const [bp, value] of sortedBreakpoints) {
|
|
67
77
|
if (width >= value.min) return bp as Breakpoint;
|
|
68
78
|
}
|
|
69
79
|
return 'xs';
|
|
70
80
|
});
|
|
71
81
|
|
|
82
|
+
// FIX: Use useRef for resize timer to prevent timer issues
|
|
83
|
+
const resizeTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
84
|
+
|
|
72
85
|
useEffect(() => {
|
|
73
|
-
// More efficient: track window resize with debounce
|
|
74
86
|
const updateBreakpoint = () => {
|
|
75
87
|
const width = window.innerWidth;
|
|
76
|
-
const sortedBreakpoints = Object.entries(BREAKPOINTS).sort(
|
|
77
|
-
([, a], [, b]) => b.min - a.min
|
|
78
|
-
);
|
|
79
88
|
|
|
80
89
|
for (const [bp, value] of sortedBreakpoints) {
|
|
81
90
|
if (width >= value.min) {
|
|
@@ -89,19 +98,22 @@ export function useBreakpoint(): UseBreakpointReturn {
|
|
|
89
98
|
updateBreakpoint();
|
|
90
99
|
|
|
91
100
|
// Debounced resize listener
|
|
92
|
-
let resizeTimer: ReturnType<typeof setTimeout>;
|
|
93
101
|
const handleResize = () => {
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
if (resizeTimerRef.current) {
|
|
103
|
+
clearTimeout(resizeTimerRef.current);
|
|
104
|
+
}
|
|
105
|
+
resizeTimerRef.current = setTimeout(updateBreakpoint, 100);
|
|
96
106
|
};
|
|
97
107
|
|
|
98
108
|
window.addEventListener('resize', handleResize);
|
|
99
109
|
|
|
100
110
|
return () => {
|
|
101
111
|
window.removeEventListener('resize', handleResize);
|
|
102
|
-
|
|
112
|
+
if (resizeTimerRef.current) {
|
|
113
|
+
clearTimeout(resizeTimerRef.current);
|
|
114
|
+
}
|
|
103
115
|
};
|
|
104
|
-
}, []);
|
|
116
|
+
}, [sortedBreakpoints]);
|
|
105
117
|
|
|
106
118
|
// Helper functions
|
|
107
119
|
const matches = useCallback(
|
|
@@ -12,8 +12,8 @@ export function useScrollLock(enabled: boolean = true) {
|
|
|
12
12
|
const originalStyle = window.getComputedStyle(document.body).overflow;
|
|
13
13
|
const originalPaddingRight = window.getComputedStyle(document.body).paddingRight;
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
15
|
+
// FIX: Ensure scrollbar width is never negative
|
|
16
|
+
const scrollbarWidth = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
|
|
17
17
|
|
|
18
18
|
document.body.style.overflow = 'hidden';
|
|
19
19
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
@@ -1,50 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useTheme Hook
|
|
3
|
-
* @description Theme toggle functionality
|
|
3
|
+
* @description Theme toggle functionality with SSR support and system preference detection
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useEffect, useState } from 'react';
|
|
6
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
7
7
|
|
|
8
|
-
export type Theme = 'light' | 'dark';
|
|
8
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
9
9
|
|
|
10
10
|
export interface UseThemeReturn {
|
|
11
11
|
theme: Theme;
|
|
12
|
+
effectiveTheme: 'light' | 'dark';
|
|
12
13
|
toggleTheme: () => void;
|
|
13
14
|
setTheme: (theme: Theme) => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
const THEME_STORAGE_KEY = 'theme';
|
|
18
|
+
|
|
19
|
+
const isValidTheme = (value: unknown): value is 'light' | 'dark' => {
|
|
20
|
+
return value === 'light' || value === 'dark';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const getSystemTheme = (): 'light' | 'dark' => {
|
|
24
|
+
if (typeof window === 'undefined') return 'light';
|
|
25
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
26
|
+
};
|
|
27
|
+
|
|
16
28
|
export function useTheme(): UseThemeReturn {
|
|
17
29
|
const [theme, setThemeState] = useState<Theme>(() => {
|
|
30
|
+
// FIX: SSR-safe initialization
|
|
31
|
+
if (typeof window === 'undefined') return 'system';
|
|
32
|
+
|
|
18
33
|
try {
|
|
19
|
-
const saved = localStorage.getItem(
|
|
20
|
-
if (saved
|
|
21
|
-
return '
|
|
34
|
+
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
|
35
|
+
if (isValidTheme(saved)) return saved;
|
|
36
|
+
if (saved === 'system') return 'system';
|
|
37
|
+
return 'system';
|
|
22
38
|
} catch {
|
|
23
|
-
return '
|
|
39
|
+
return 'system';
|
|
24
40
|
}
|
|
25
41
|
});
|
|
26
42
|
|
|
43
|
+
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>(() => {
|
|
44
|
+
if (theme === 'system') {
|
|
45
|
+
return getSystemTheme();
|
|
46
|
+
}
|
|
47
|
+
return theme;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Listen for system theme changes
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (theme !== 'system') return;
|
|
53
|
+
|
|
54
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
55
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
56
|
+
setEffectiveTheme(e.matches ? 'dark' : 'light');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
60
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
61
|
+
}, [theme]);
|
|
62
|
+
|
|
27
63
|
useEffect(() => {
|
|
28
64
|
const root = document.documentElement;
|
|
65
|
+
const themeToApply = theme === 'system' ? getSystemTheme() : theme;
|
|
66
|
+
|
|
29
67
|
root.classList.remove('light', 'dark');
|
|
30
|
-
root.classList.add(
|
|
68
|
+
root.classList.add(themeToApply);
|
|
69
|
+
|
|
31
70
|
try {
|
|
32
|
-
localStorage.setItem(
|
|
71
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
33
72
|
} catch {
|
|
34
|
-
// Ignore
|
|
73
|
+
// Ignore storage errors
|
|
35
74
|
}
|
|
36
75
|
}, [theme]);
|
|
37
76
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
77
|
+
// FIX: Memoize callbacks to prevent unnecessary re-renders
|
|
78
|
+
const toggleTheme = useCallback(() => {
|
|
79
|
+
setThemeState((prev) => {
|
|
80
|
+
if (prev === 'light') return 'dark';
|
|
81
|
+
if (prev === 'dark') return 'system';
|
|
82
|
+
return 'light';
|
|
83
|
+
});
|
|
84
|
+
}, []);
|
|
41
85
|
|
|
42
|
-
const setTheme = (newTheme: Theme) => {
|
|
86
|
+
const setTheme = useCallback((newTheme: Theme) => {
|
|
43
87
|
setThemeState(newTheme);
|
|
44
|
-
};
|
|
88
|
+
}, []);
|
|
45
89
|
|
|
46
90
|
return {
|
|
47
91
|
theme,
|
|
92
|
+
effectiveTheme: theme === 'system' ? effectiveTheme : theme,
|
|
48
93
|
toggleTheme,
|
|
49
94
|
setTheme,
|
|
50
95
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Dialog/overlay container with optimized transitions
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { forwardRef, type HTMLAttributes, useEffect, useState } from 'react';
|
|
6
|
+
import { forwardRef, type HTMLAttributes, useEffect, useState, useRef } from 'react';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
import { cn } from '../../infrastructure/utils';
|
|
9
9
|
import type { BaseProps } from '../../domain/types';
|
|
@@ -27,22 +27,33 @@ export const Modal = React.memo(forwardRef<HTMLDivElement, ModalProps>(
|
|
|
27
27
|
({ open = false, onClose, showCloseButton = true, size = 'md', className, children, ...props }, ref) => {
|
|
28
28
|
const [shouldRender, setShouldRender] = useState(open);
|
|
29
29
|
const [isAnimating, setIsAnimating] = useState(false);
|
|
30
|
+
const rafRef = useRef<number>();
|
|
31
|
+
const timerRef = useRef<number>();
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
if (open) {
|
|
33
35
|
setShouldRender(true);
|
|
34
|
-
//
|
|
35
|
-
requestAnimationFrame(() => {
|
|
36
|
+
// FIX: Store rafId and cleanup properly
|
|
37
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
36
38
|
setIsAnimating(true);
|
|
37
39
|
});
|
|
38
40
|
} else {
|
|
39
41
|
setIsAnimating(false);
|
|
40
42
|
// Wait for animation to complete before unmounting
|
|
41
|
-
|
|
43
|
+
timerRef.current = window.setTimeout(() => {
|
|
42
44
|
setShouldRender(false);
|
|
43
45
|
}, 200); // Match animation duration
|
|
44
|
-
return () => clearTimeout(timer);
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
// FIX: Proper cleanup for both animation frame and timeout
|
|
49
|
+
return () => {
|
|
50
|
+
if (rafRef.current) {
|
|
51
|
+
cancelAnimationFrame(rafRef.current);
|
|
52
|
+
}
|
|
53
|
+
if (timerRef.current) {
|
|
54
|
+
clearTimeout(timerRef.current);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
46
57
|
}, [open]);
|
|
47
58
|
|
|
48
59
|
if (!shouldRender) return null;
|