@umituz/web-design-system 3.1.10 → 3.1.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-design-system",
3
- "version": "3.1.10",
3
+ "version": "3.1.12",
4
4
  "private": false,
5
5
  "description": "Web Design System - Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications",
6
6
  "main": "./src/index.ts",
@@ -38,22 +38,22 @@ export const validateInput = (
38
38
 
39
39
  // HTML tag check for non-HTML content
40
40
  if (!config?.allowHtml) {
41
- if (/<[^>]*>/.test(trimmed)) {
42
- return { isValid: false, error: "HTML tags are not allowed" };
43
- }
44
-
45
- // Check for suspicious patterns
46
- const suspiciousPatterns = [
47
- /javascript:/gi,
48
- /vbscript:/gi,
49
- /data:text\/html/gi,
50
- /data:application\/javascript/gi,
51
- /on\w+\s*=/gi,
41
+ // FIX: Better HTML detection that catches encoded variations
42
+ const htmlPatterns = [
43
+ /<[^>]*>/gi, // Standard HTML tags
44
+ /&lt;[^&]*&gt;/gi, // HTML encoded tags
45
+ /&lt;[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 suspiciousPatterns) {
54
+ for (const pattern of htmlPatterns) {
55
55
  if (pattern.test(trimmed)) {
56
- return { isValid: false, error: "Potentially unsafe content detected" };
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
- // Check for dangerous patterns
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
- /\.\./g, // Directory traversal
186
- /[<>:"|?*]/g, // Invalid file name characters
187
- /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i, // Windows reserved names
188
- /^\./g, // Hidden files starting with dot
189
- /\.$/, // Files ending with dot
190
- /\s+$/ // Trailing whitespace
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: "Invalid file name format" };
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
- // Check for inline scripts
215
- if (/<script[^>]*>/.test(content)) {
216
- violations.push("Inline scripts not allowed");
217
- }
218
-
219
- // Check for inline styles
220
- if (/style\s*=/.test(content)) {
221
- violations.push("Inline styles not allowed");
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
- // Check for javascript: URLs
225
- if (/javascript:/gi.test(content)) {
226
- violations.push("JavaScript URLs not allowed");
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 data: URLs with dangerous content
230
- if (/data:(?:text\/html|application\/javascript)/gi.test(content)) {
231
- violations.push("Dangerous data URLs not allowed");
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
- this.cache.delete(firstKey);
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
- // Cache key generator
55
+ // FIX: Better cache key generator that handles functions and undefined
53
56
  function generateCacheKey(inputs: ClassValue[]): string {
54
- return JSON.stringify(inputs);
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
- const cacheKey = generateCacheKey(inputs);
59
- const cached = classNameCache.get(cacheKey);
87
+ // Skip cache for dynamic inputs that might contain functions
88
+ const hasFunction = inputs.some((input) => typeof input === 'function');
60
89
 
61
- if (cached) {
62
- return cached;
63
- }
90
+ if (!hasFunction) {
91
+ const cacheKey = generateCacheKey(inputs);
92
+ const cached = classNameCache.get(cacheKey);
64
93
 
65
- const result = twMerge(clsx(inputs));
66
- classNameCache.set(cacheKey, result);
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
- return result;
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
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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
- props.onChange?.(e);
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 px-3 text-sm',
18
- md: 'h-9 px-3 text-sm',
19
- lg: 'h-10 px-4 text-base',
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 px-3 py-2',
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 h-6 w-11 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',
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
- if (ref.current && !ref.current.contains(event.target as Node)) {
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
- }, delay - timeSinceLastRun);
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
- useEffect(() => {
27
- if (!enabled) return;
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
- const handleKeyDown = (event: KeyboardEvent) => {
30
- const keyMatches = event.key.toLowerCase() === key.toLowerCase();
31
- const modifiersMatch = modifiers.every((mod) => {
32
- switch (mod) {
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
- if (keyMatches && modifiersMatch) {
47
- onKeyDown?.(event);
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
- const handleKeyUp = (event: KeyboardEvent) => {
52
- const keyMatches = event.key.toLowerCase() === key.toLowerCase();
53
- const modifiersMatch = modifiers.every((mod) => {
54
- switch (mod) {
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
- if (keyMatches && modifiersMatch) {
69
- onKeyUp?.(event);
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
- }, [key, modifiers, onKeyDown, onKeyUp, enabled]);
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
- const valueToStore = value instanceof Function ? value(valueRef.current) : value;
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
- // Initialize with current window size
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 Object.entries(BREAKPOINTS).reverse()) {
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
- clearTimeout(resizeTimer);
95
- resizeTimer = setTimeout(updateBreakpoint, 100);
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
- clearTimeout(resizeTimer);
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
- // Calculate scrollbar width
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('theme') as Theme | null;
20
- if (saved === 'light' || saved === 'dark') return saved;
21
- return 'light';
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 'light';
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(theme);
68
+ root.classList.add(themeToApply);
69
+
31
70
  try {
32
- localStorage.setItem('theme', theme);
71
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
33
72
  } catch {
34
- // Ignore
73
+ // Ignore storage errors
35
74
  }
36
75
  }, [theme]);
37
76
 
38
- const toggleTheme = () => {
39
- setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
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
- // Small delay to trigger animation
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
- const timer = setTimeout(() => {
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;