@umituz/web-design-system 3.1.22 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +137 -469
  2. package/dist/styles.css +2 -0
  3. package/package.json +31 -31
  4. package/src/__tests__/cn.test.ts +24 -0
  5. package/src/__tests__/components.smoke.test.tsx +78 -0
  6. package/src/__tests__/styles.contract.test.ts +60 -0
  7. package/src/domain/tokens/index.ts +8 -49
  8. package/src/index.ts +8 -10
  9. package/src/infrastructure/constants/component.constants.ts +3 -17
  10. package/src/infrastructure/constants/index.ts +4 -2
  11. package/src/infrastructure/constants/timing.constants.ts +46 -0
  12. package/src/infrastructure/utils/calculation.utils.ts +74 -0
  13. package/src/infrastructure/utils/cn.util.ts +1 -1
  14. package/src/infrastructure/utils/index.ts +12 -0
  15. package/src/presentation/atoms/IconButton.tsx +55 -0
  16. package/src/presentation/atoms/Progress.tsx +2 -1
  17. package/src/presentation/atoms/Spinner.tsx +1 -1
  18. package/src/presentation/atoms/Switch.tsx +2 -2
  19. package/src/presentation/atoms/Text.tsx +15 -13
  20. package/src/presentation/atoms/Tooltip.tsx +2 -1
  21. package/src/presentation/atoms/index.ts +3 -0
  22. package/src/presentation/hooks/index.ts +0 -3
  23. package/src/presentation/hooks/useClickOutside.ts +16 -10
  24. package/src/presentation/hooks/useClipboard.ts +30 -12
  25. package/src/presentation/hooks/useDebounce.ts +9 -11
  26. package/src/presentation/hooks/useLocalStorage.ts +32 -20
  27. package/src/presentation/hooks/useMediaQuery.ts +20 -15
  28. package/src/presentation/hooks/useScrollLock.ts +40 -12
  29. package/src/presentation/hooks/useToggle.ts +8 -6
  30. package/src/presentation/molecules/ActiveFilterTags.tsx +19 -18
  31. package/src/presentation/molecules/Avatar.tsx +1 -1
  32. package/src/presentation/molecules/CodeBlock.tsx +50 -48
  33. package/src/presentation/molecules/InputGroup.tsx +1 -1
  34. package/src/presentation/molecules/ListItem.tsx +40 -29
  35. package/src/presentation/molecules/Select.tsx +1 -1
  36. package/src/presentation/molecules/Toggle.tsx +4 -4
  37. package/src/presentation/molecules/index.ts +0 -3
  38. package/src/presentation/organisms/Alert.tsx +1 -1
  39. package/src/presentation/organisms/DataTable.tsx +26 -15
  40. package/src/presentation/organisms/Dialog.tsx +1 -1
  41. package/src/presentation/organisms/DropdownMenu.tsx +2 -2
  42. package/src/presentation/organisms/HoverCard.tsx +1 -1
  43. package/src/presentation/organisms/LoadingState.tsx +1 -5
  44. package/src/presentation/organisms/Popover.tsx +1 -1
  45. package/src/presentation/organisms/StatCard.tsx +2 -2
  46. package/src/presentation/organisms/Table.tsx +2 -2
  47. package/src/presentation/organisms/index.ts +0 -60
  48. package/src/styles/compile.css +18 -0
  49. package/src/styles/theme.css +131 -0
  50. package/src/domain/tokens/animation.tokens.ts +0 -87
  51. package/src/domain/tokens/color.tokens.ts +0 -95
  52. package/src/domain/tokens/radius.tokens.ts +0 -27
  53. package/src/domain/tokens/shadow.tokens.ts +0 -25
  54. package/src/domain/tokens/spacing.tokens.ts +0 -65
  55. package/src/domain/tokens/typography.tokens.ts +0 -74
  56. package/src/infrastructure/error/ErrorBoundary.tsx +0 -258
  57. package/src/infrastructure/error/ErrorDisplay.tsx +0 -346
  58. package/src/infrastructure/error/SuspenseWrapper.tsx +0 -133
  59. package/src/infrastructure/error/index.ts +0 -5
  60. package/src/infrastructure/performance/index.ts +0 -92
  61. package/src/infrastructure/performance/useLazyLoading.ts +0 -342
  62. package/src/infrastructure/performance/useMemoryOptimization.ts +0 -293
  63. package/src/infrastructure/performance/usePerformanceMonitor.ts +0 -158
  64. package/src/infrastructure/security/index.ts +0 -10
  65. package/src/infrastructure/security/security-config.ts +0 -171
  66. package/src/infrastructure/security/useFormValidation.ts +0 -216
  67. package/src/infrastructure/security/validation.ts +0 -246
  68. package/src/presentation/hooks/useLanguage.ts +0 -109
  69. package/src/presentation/molecules/Breadcrumb.tsx +0 -54
  70. package/src/presentation/organisms/AlertDialog.tsx +0 -181
  71. package/src/presentation/organisms/Calendar.tsx +0 -79
  72. package/src/presentation/organisms/Comments.tsx +0 -103
  73. package/src/presentation/organisms/ConfirmDialog.tsx +0 -97
  74. package/src/presentation/organisms/FilterBar.tsx +0 -244
  75. package/src/presentation/organisms/FilterSidebar.tsx +0 -172
  76. package/src/presentation/organisms/Footer.tsx +0 -86
  77. package/src/presentation/organisms/FormModal.tsx +0 -126
  78. package/src/presentation/organisms/Grid.tsx +0 -128
  79. package/src/presentation/organisms/ImageLightbox.tsx +0 -252
  80. package/src/presentation/organisms/MainNavbar.tsx +0 -320
  81. package/src/presentation/organisms/Modal.tsx +0 -173
  82. package/src/presentation/organisms/Navbar.tsx +0 -75
  83. package/src/presentation/organisms/NewsletterSignup.tsx +0 -165
  84. package/src/presentation/organisms/QuickActionCard.tsx +0 -93
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Calculation Utils
3
+ * @description Small numeric helpers used by presentation components
4
+ * (progress percentage, pagination bounds, trend formatting, clamping)
5
+ */
6
+
7
+ // --- Clamping ---
8
+
9
+ export const clampToRange = (value: number, min: number, max: number): number => {
10
+ if (Number.isNaN(value)) return min;
11
+ if (min > max) [min, max] = [max, min];
12
+ if (value < min) return min;
13
+ if (value > max) return max;
14
+ return value;
15
+ };
16
+
17
+ export const clampToNonNegative = (value: number): number =>
18
+ value < 0 || Number.isNaN(value) ? 0 : value;
19
+
20
+ export const clampToPositive = (value: number, fallback: number = 1): number =>
21
+ value <= 0 || Number.isNaN(value) ? fallback : value;
22
+
23
+ // --- Percentage ---
24
+
25
+ const PERCENT_MIN = 0;
26
+ const PERCENT_MAX = 100;
27
+
28
+ export const percentOfMax = (value: number, max: number): number => {
29
+ if (max <= 0) return PERCENT_MIN;
30
+ return clampToRange((value / max) * PERCENT_MAX, PERCENT_MIN, PERCENT_MAX);
31
+ };
32
+
33
+ // --- Trend formatting ---
34
+
35
+ const PERCENT_FRACTION_DIGITS = 1;
36
+ const SIGN_THRESHOLD = 0;
37
+ const POSITIVE_SIGN = '+';
38
+ const NEGATIVE_SIGN = '-';
39
+
40
+ export const formatTrendMagnitude = (value: number, fractionDigits: number = PERCENT_FRACTION_DIGITS): string => {
41
+ if (Number.isNaN(value)) return '0%';
42
+ const sign = value > SIGN_THRESHOLD ? POSITIVE_SIGN : value < SIGN_THRESHOLD ? NEGATIVE_SIGN : '';
43
+ return `${sign}${Math.abs(value).toFixed(fractionDigits)}%`;
44
+ };
45
+
46
+ // --- Pagination bounds ---
47
+
48
+ export interface PageBounds {
49
+ totalPages: number;
50
+ startIndex: number;
51
+ endIndex: number;
52
+ }
53
+
54
+ export const computeTotalPages = (totalItems: number, pageSize: number): number => {
55
+ if (pageSize <= 0) return 0;
56
+ return Math.ceil(totalItems / pageSize);
57
+ };
58
+
59
+ export const clampPageNumber = (
60
+ page: number,
61
+ totalPages: number
62
+ ): number => clampToRange(page, 1, Math.max(totalPages, 1));
63
+
64
+ export const computePageBounds = (
65
+ currentPage: number,
66
+ pageSize: number,
67
+ totalItems: number
68
+ ): PageBounds => {
69
+ const totalPages = computeTotalPages(totalItems, pageSize);
70
+ const safePage = clampPageNumber(currentPage, totalPages);
71
+ const startIndex = (safePage - 1) * pageSize + 1;
72
+ const endIndex = Math.min(safePage * pageSize, totalItems);
73
+ return { totalPages, startIndex, endIndex };
74
+ };
@@ -68,7 +68,7 @@ function generateCacheKey(inputs: ClassValue[]): string {
68
68
  // Handle objects (like conditional objects in clsx)
69
69
  return JSON.stringify(
70
70
  Object.entries(input)
71
- .filter(([_, value]) => Boolean(value))
71
+ .filter(([, value]) => Boolean(value))
72
72
  .map(([key]) => key)
73
73
  .sort()
74
74
  );
@@ -18,3 +18,15 @@ export {
18
18
  getSpaceY,
19
19
  getResponsiveStyles,
20
20
  } from './responsive.utils';
21
+
22
+ export {
23
+ clampToRange,
24
+ clampToNonNegative,
25
+ clampToPositive,
26
+ percentOfMax,
27
+ formatTrendMagnitude,
28
+ computeTotalPages,
29
+ clampPageNumber,
30
+ computePageBounds,
31
+ } from './calculation.utils';
32
+ export type { PageBounds } from './calculation.utils';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * IconButton Component (Atom)
3
+ * @description Round button optimized for icon-only usage with consistent sizing
4
+ */
5
+
6
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
7
+ import { cn } from '../../infrastructure/utils';
8
+ import type { BaseProps } from '../../domain/types';
9
+ import type { SmallSizes, MediumSizes } from '../../infrastructure/constants';
10
+
11
+ export interface IconButtonProps
12
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,
13
+ BaseProps {
14
+ icon: ReactNode;
15
+ label: string;
16
+ size?: SmallSizes | MediumSizes;
17
+ variant?: 'default' | 'ghost' | 'outline';
18
+ }
19
+
20
+ const sizeStyles: Record<SmallSizes | MediumSizes, string> = {
21
+ xs: 'h-7 w-7',
22
+ sm: 'h-8 w-8',
23
+ md: 'h-10 w-10',
24
+ lg: 'h-12 w-12',
25
+ };
26
+
27
+ const variantStyles: Record<'default' | 'ghost' | 'outline', string> = {
28
+ default: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
29
+ ghost: 'text-muted-foreground hover:bg-muted hover:text-foreground',
30
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
31
+ };
32
+
33
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
34
+ ({ className, icon, label, size = 'md', variant = 'ghost', type = 'button', ...props }, ref) => (
35
+ <button
36
+ ref={ref}
37
+ type={type}
38
+ aria-label={label}
39
+ title={label}
40
+ className={cn(
41
+ 'inline-flex shrink-0 items-center justify-center rounded-md transition-colors',
42
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
43
+ 'disabled:pointer-events-none disabled:opacity-50',
44
+ sizeStyles[size],
45
+ variantStyles[variant],
46
+ className
47
+ )}
48
+ {...props}
49
+ >
50
+ {icon}
51
+ </button>
52
+ )
53
+ );
54
+
55
+ IconButton.displayName = 'IconButton';
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { forwardRef, type HTMLAttributes } from 'react';
7
7
  import { cn } from '../../infrastructure/utils';
8
+ import { percentOfMax } from '../../infrastructure/utils/calculation.utils';
8
9
  import type { BaseProps } from '../../domain/types';
9
10
 
10
11
  export interface ProgressProps extends HTMLAttributes<HTMLDivElement>, BaseProps {
@@ -21,7 +22,7 @@ const sizeStyles: Record<'sm' | 'md' | 'lg', string> = {
21
22
 
22
23
  export const Progress = forwardRef<HTMLDivElement, ProgressProps>(
23
24
  ({ className, value = 0, max = 100, size = 'md', ...props }, ref) => {
24
- const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
25
+ const percentage = percentOfMax(value, max);
25
26
 
26
27
  return (
27
28
  <div
@@ -15,7 +15,7 @@ export interface SpinnerProps extends HTMLAttributes<HTMLDivElement>, BaseProps
15
15
  const sizeStyles: Record<RegularSizes, string> = {
16
16
  sm: 'h-4 w-4 border-2',
17
17
  md: 'h-6 w-6 border-2',
18
- lg: 'h-8 w-8 border-3',
18
+ lg: 'h-8 w-8 border-[3px]',
19
19
  xl: 'h-12 w-12 border-4',
20
20
  };
21
21
 
@@ -16,7 +16,7 @@ const switchVariants = cva(
16
16
  size: {
17
17
  default: 'h-6 w-11',
18
18
  sm: 'h-5 w-9',
19
- lg: 'h-7 w-13',
19
+ lg: 'h-7 w-14',
20
20
  },
21
21
  },
22
22
  defaultVariants: {
@@ -32,7 +32,7 @@ const switchThumbVariants = cva(
32
32
  size: {
33
33
  default: 'h-5 w-5 data-[state=checked]:translate-x-5',
34
34
  sm: 'h-4 w-4 data-[state=checked]:translate-x-4',
35
- lg: 'h-6 w-6 data-[state=checked]:translate-x-6',
35
+ lg: 'h-6 w-6 data-[state=checked]:translate-x-7',
36
36
  },
37
37
  },
38
38
  defaultVariants: {
@@ -1,23 +1,27 @@
1
1
  /**
2
2
  * Text Component (Atom)
3
- * @description Styled text element
3
+ * @description Styled text element with polymorphic rendering
4
4
  */
5
5
 
6
- import { forwardRef, type HTMLAttributes } from 'react';
7
- import React from 'react';
6
+ import { forwardRef, memo, type ElementType, type ComponentPropsWithoutRef } from 'react';
8
7
  import { cn } from '../../infrastructure/utils';
9
8
  import type { BaseProps } from '../../domain/types';
10
9
 
11
10
  export type TextElement = 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label';
12
11
  export type TextVariant = 'body' | 'heading' | 'label' | 'caption';
13
12
  export type TextSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
13
+ export type TextWeight = 'normal' | 'medium' | 'semibold' | 'bold';
14
14
 
15
- export interface TextProps extends HTMLAttributes<HTMLElement>, BaseProps {
15
+ type TextComponentProps = {
16
16
  as?: TextElement;
17
17
  variant?: TextVariant;
18
18
  size?: TextSize;
19
- weight?: 'normal' | 'medium' | 'semibold' | 'bold';
20
- }
19
+ weight?: TextWeight;
20
+ };
21
+
22
+ export type TextProps = TextComponentProps &
23
+ Omit<ComponentPropsWithoutRef<'p'>, keyof TextComponentProps> &
24
+ BaseProps;
21
25
 
22
26
  const variantStyles: Record<TextVariant, string> = {
23
27
  body: 'text-foreground',
@@ -37,20 +41,18 @@ const sizeStyles: Record<TextSize, string> = {
37
41
  '4xl': 'text-4xl',
38
42
  };
39
43
 
40
- const weightStyles: Record<'normal' | 'medium' | 'semibold' | 'bold', string> = {
44
+ const weightStyles: Record<TextWeight, string> = {
41
45
  normal: 'font-normal',
42
46
  medium: 'font-medium',
43
47
  semibold: 'font-semibold',
44
48
  bold: 'font-bold',
45
49
  };
46
50
 
47
- // NOTE: "as any" is used here for the polymorphic ref, which is a necessary workaround
48
- // for TypeScript's limitations in typing polymorphic components properly.
49
- export const Text = React.memo(forwardRef<HTMLElement, TextProps>(
50
- ({ className, as = 'p', variant = 'body', size = 'md', weight = 'normal', ...props }, ref) => {
51
- const Tag = as as any;
51
+ export const Text = memo(forwardRef<HTMLElement, TextProps>(
52
+ ({ className, as: Tag = 'p', variant = 'body', size = 'md', weight = 'normal', ...props }, ref) => {
53
+ const Element = Tag as ElementType;
52
54
  return (
53
- <Tag
55
+ <Element
54
56
  ref={ref}
55
57
  className={cn(
56
58
  variantStyles[variant],
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { forwardRef, type HTMLAttributes, type ReactNode, useState, useRef, useEffect } from 'react';
7
7
  import { cn } from '../../infrastructure/utils';
8
+ import { TIMING } from '../../infrastructure/constants/timing.constants';
8
9
  import type { BaseProps } from '../../domain/types';
9
10
 
10
11
  export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'>, BaseProps {
@@ -21,7 +22,7 @@ const placementStyles: Record<'top' | 'bottom' | 'left' | 'right', string> = {
21
22
  };
22
23
 
23
24
  export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
24
- ({ className, children, content, placement = 'top', delay = 200, ...props }, ref) => {
25
+ ({ className, children, content, placement = 'top', delay = TIMING.TOOLTIP_DEFAULT_DELAY_MS, ...props }, ref) => {
25
26
  const [isOpen, setIsOpen] = useState(false);
26
27
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
27
28
 
@@ -63,3 +63,6 @@ export type { ShowComponentProps } from './Show';
63
63
 
64
64
  export { Hide } from './Hide';
65
65
  export type { HideComponentProps } from './Hide';
66
+
67
+ export { IconButton } from './IconButton';
68
+ export type { IconButtonProps } from './IconButton';
@@ -25,6 +25,3 @@ export type { UseClipboardReturn } from './useClipboard';
25
25
  export { useToggle } from './useToggle';
26
26
 
27
27
  export { useScrollLock } from './useScrollLock';
28
-
29
- export { useLanguage } from './useLanguage';
30
- export type { Language, SupportedLanguage, UseLanguageReturn } from './useLanguage';
@@ -10,26 +10,32 @@ export function useClickOutside<T extends HTMLElement>(
10
10
  enabled: boolean = true
11
11
  ) {
12
12
  const ref = useRef<T>(null);
13
+ // Use a ref to keep the latest callback without retriggering the effect
14
+ // on every render — callers commonly pass inline functions.
15
+ const callbackRef = useRef(callback);
16
+ useEffect(() => {
17
+ callbackRef.current = callback;
18
+ });
13
19
 
14
20
  useEffect(() => {
15
21
  if (!enabled) return;
16
22
 
17
- const handleClick = (event: Event) => {
18
- // FIX: Safe null check and type guard
19
- const target = event.target as Node;
20
- if (ref.current && target && !ref.current.contains(target)) {
21
- callback();
23
+ const handlePointerDown = (event: Event) => {
24
+ const target = event.target;
25
+ if (!(target instanceof Node)) return;
26
+ if (ref.current && !ref.current.contains(target)) {
27
+ callbackRef.current();
22
28
  }
23
29
  };
24
30
 
25
- document.addEventListener('mousedown', handleClick);
26
- document.addEventListener('touchstart', handleClick);
31
+ document.addEventListener('mousedown', handlePointerDown);
32
+ document.addEventListener('touchstart', handlePointerDown);
27
33
 
28
34
  return () => {
29
- document.removeEventListener('mousedown', handleClick);
30
- document.removeEventListener('touchstart', handleClick);
35
+ document.removeEventListener('mousedown', handlePointerDown);
36
+ document.removeEventListener('touchstart', handlePointerDown);
31
37
  };
32
- }, [callback, enabled]);
38
+ }, [enabled]);
33
39
 
34
40
  return ref;
35
41
  }
@@ -1,38 +1,56 @@
1
1
  /**
2
2
  * useClipboard Hook
3
- * @description Clipboard operations
3
+ * @description Clipboard operations with proper cleanup
4
4
  */
5
5
 
6
- import { useState, useCallback } from 'react';
6
+ import { useState, useCallback, useEffect, useRef } from 'react';
7
+ import { TIMING } from '../../infrastructure/constants/timing.constants';
7
8
 
8
9
  export interface UseClipboardReturn {
9
10
  value: string;
10
11
  copied: boolean;
11
- copy: (text: string) => Promise<void>;
12
+ copy: (text: string) => Promise<boolean>;
12
13
  }
13
14
 
14
15
  export function useClipboard(): UseClipboardReturn {
15
16
  const [value, setValue] = useState<string>('');
16
17
  const [copied, setCopied] = useState<boolean>(false);
18
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19
+
20
+ useEffect(() => {
21
+ return () => {
22
+ if (timeoutRef.current !== null) {
23
+ clearTimeout(timeoutRef.current);
24
+ }
25
+ };
26
+ }, []);
27
+
28
+ const copy = useCallback(async (text: string): Promise<boolean> => {
29
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
30
+ console.error('[useClipboard] Clipboard API is not available in this environment.');
31
+ return false;
32
+ }
17
33
 
18
- const copy = useCallback(async (text: string) => {
19
34
  try {
20
35
  await navigator.clipboard.writeText(text);
21
36
  setValue(text);
22
37
  setCopied(true);
23
38
 
24
- setTimeout(() => {
39
+ if (timeoutRef.current !== null) {
40
+ clearTimeout(timeoutRef.current);
41
+ }
42
+ timeoutRef.current = setTimeout(() => {
25
43
  setCopied(false);
26
- }, 2000);
44
+ timeoutRef.current = null;
45
+ }, TIMING.CLIPBOARD_RESET_MS);
46
+
47
+ return true;
27
48
  } catch (error) {
28
- console.error('Failed to copy:', error);
49
+ console.error('[useClipboard] Failed to copy text:', error);
29
50
  setCopied(false);
51
+ return false;
30
52
  }
31
53
  }, []);
32
54
 
33
- return {
34
- value,
35
- copied,
36
- copy,
37
- };
55
+ return { value, copied, copy };
38
56
  }
@@ -4,25 +4,24 @@
4
4
  */
5
5
 
6
6
  import { useState, useEffect, useRef, useCallback } from 'react';
7
+ import { TIMING } from '../../infrastructure/constants/timing.constants';
8
+ import { clampToNonNegative } from '../../infrastructure/utils/calculation.utils';
7
9
 
8
- export function useDebounce<T>(value: T, delay: number = 500): T {
10
+ export function useDebounce<T>(value: T, delay: number = TIMING.DEBOUNCE_DEFAULT_MS): T {
9
11
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
10
12
  const timeoutRef = useRef<number | null>(null);
11
13
 
12
14
  useEffect(() => {
13
- // Clear previous timeout
14
- if (timeoutRef.current) {
15
+ if (timeoutRef.current !== null) {
15
16
  clearTimeout(timeoutRef.current);
16
17
  }
17
18
 
18
- // Set new timeout
19
19
  timeoutRef.current = window.setTimeout(() => {
20
20
  setDebouncedValue(value);
21
21
  }, delay);
22
22
 
23
- // Cleanup
24
23
  return () => {
25
- if (timeoutRef.current) {
24
+ if (timeoutRef.current !== null) {
26
25
  clearTimeout(timeoutRef.current);
27
26
  }
28
27
  };
@@ -35,9 +34,9 @@ export function useDebounce<T>(value: T, delay: number = 500): T {
35
34
  * useThrottle Hook
36
35
  * @description Throttle a function to limit execution rate
37
36
  */
38
- export function useThrottle<T extends (...args: any[]) => any>(
37
+ export function useThrottle<T extends (...args: unknown[]) => unknown>(
39
38
  func: T,
40
- delay: number = 300
39
+ delay: number = TIMING.THROTTLE_DEFAULT_MS
41
40
  ): T {
42
41
  const lastRun = useRef<Date>(new Date());
43
42
  const timeoutRef = useRef<number | null>(null);
@@ -51,12 +50,11 @@ export function useThrottle<T extends (...args: any[]) => any>(
51
50
  return func(...args);
52
51
  }
53
52
 
54
- if (timeoutRef.current) {
53
+ if (timeoutRef.current !== null) {
55
54
  clearTimeout(timeoutRef.current);
56
55
  }
57
56
 
58
- // FIX: Ensure delay is never negative
59
- const delayUntilNextExecution = Math.max(0, delay - timeSinceLastRun);
57
+ const delayUntilNextExecution = clampToNonNegative(delay - timeSinceLastRun);
60
58
  timeoutRef.current = window.setTimeout(() => {
61
59
  lastRun.current = new Date();
62
60
  func(...args);
@@ -1,47 +1,59 @@
1
1
  /**
2
2
  * useLocalStorage Hook
3
- * @description LocalStorage state management with optimized re-renders
3
+ * @description LocalStorage state management with safe error surfacing
4
4
  */
5
5
 
6
- import { useCallback, useState, useRef } from 'react';
6
+ import { useCallback, useEffect, useState, useRef } from 'react';
7
+
8
+ const isBrowser = (): boolean =>
9
+ typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
10
+
11
+ const readInitial = <T,>(key: string, initialValue: T): T => {
12
+ if (!isBrowser()) return initialValue;
13
+ try {
14
+ const raw = window.localStorage.getItem(key);
15
+ return raw === null ? initialValue : (JSON.parse(raw) as T);
16
+ } catch (error) {
17
+ console.error(`[useLocalStorage] Failed to read key "${key}":`, error);
18
+ return initialValue;
19
+ }
20
+ };
7
21
 
8
22
  export function useLocalStorage<T>(
9
23
  key: string,
10
24
  initialValue: T
11
25
  ): [T, (value: T | ((prev: T) => T)) => void, () => void] {
12
- const [storedValue, setStoredValue] = useState<T>(() => {
13
- try {
14
- const item = window.localStorage.getItem(key);
15
- return item ? JSON.parse(item) : initialValue;
16
- } catch {
17
- return initialValue;
18
- }
19
- });
26
+ const [storedValue, setStoredValue] = useState<T>(() => readInitial(key, initialValue));
20
27
 
21
- // Use ref to track latest value without causing re-renders
22
28
  const valueRef = useRef(storedValue);
23
- valueRef.current = storedValue;
29
+ useEffect(() => {
30
+ valueRef.current = storedValue;
31
+ }, [storedValue]);
24
32
 
25
33
  const setValue = useCallback(
26
34
  (value: T | ((prev: T) => T)) => {
35
+ const next =
36
+ typeof value === 'function' ? (value as (prev: T) => T)(valueRef.current) : value;
37
+
38
+ setStoredValue(next);
39
+
40
+ if (!isBrowser()) return;
27
41
  try {
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;
30
- setStoredValue(valueToStore);
31
- window.localStorage.setItem(key, JSON.stringify(valueToStore));
42
+ window.localStorage.setItem(key, JSON.stringify(next));
32
43
  } catch (error) {
33
- console.error(`Error setting localStorage key "${key}":`, error);
44
+ console.error(`[useLocalStorage] Failed to write key "${key}":`, error);
34
45
  }
35
46
  },
36
- [key] // Remove storedValue dependency
47
+ [key]
37
48
  );
38
49
 
39
50
  const removeValue = useCallback(() => {
51
+ setStoredValue(initialValue);
52
+ if (!isBrowser()) return;
40
53
  try {
41
54
  window.localStorage.removeItem(key);
42
- setStoredValue(initialValue);
43
55
  } catch (error) {
44
- console.error(`Error removing localStorage key "${key}":`, error);
56
+ console.error(`[useLocalStorage] Failed to remove key "${key}":`, error);
45
57
  }
46
58
  }, [key, initialValue]);
47
59
 
@@ -3,7 +3,7 @@
3
3
  * @description Enhanced responsive breakpoint detection with helper functions
4
4
  */
5
5
 
6
- import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
6
+ import { useEffect, useState, useCallback, useRef, useMemo, useSyncExternalStore } from 'react';
7
7
  import type {
8
8
  Breakpoint,
9
9
  UseBreakpointReturn,
@@ -14,6 +14,7 @@ import {
14
14
  isBreakpointGreaterThan,
15
15
  isBreakpointLessThan,
16
16
  } from '../../infrastructure/constants/breakpoint.constants';
17
+ import { TIMING } from '../../infrastructure/constants/timing.constants';
17
18
 
18
19
  /**
19
20
  * Simple media query hook for a single breakpoint
@@ -26,22 +27,26 @@ import {
26
27
  * ```
27
28
  */
28
29
  export function useMediaQuery(breakpoint: Breakpoint): boolean {
29
- const [matches, setMatches] = useState(false);
30
-
31
- useEffect(() => {
32
- // FIX: SSR-safe check
33
- if (typeof window === 'undefined') return;
30
+ const query = useMemo(() => createMediaQuery(breakpoint), [breakpoint]);
31
+
32
+ const subscribe = useCallback(
33
+ (onStoreChange: () => void) => {
34
+ if (typeof window === 'undefined') return () => {};
35
+ const media = window.matchMedia(query);
36
+ media.addEventListener('change', onStoreChange);
37
+ return () => media.removeEventListener('change', onStoreChange);
38
+ },
39
+ [query]
40
+ );
34
41
 
35
- const query = createMediaQuery(breakpoint);
36
- const media = window.matchMedia(query);
37
- setMatches(media.matches);
42
+ const getSnapshot = useCallback(() => {
43
+ if (typeof window === 'undefined') return false;
44
+ return window.matchMedia(query).matches;
45
+ }, [query]);
38
46
 
39
- const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
40
- media.addEventListener('change', listener);
41
- return () => media.removeEventListener('change', listener);
42
- }, [breakpoint]);
47
+ const getServerSnapshot = useCallback(() => false, []);
43
48
 
44
- return matches;
49
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
45
50
  }
46
51
 
47
52
  /**
@@ -102,7 +107,7 @@ export function useBreakpoint(): UseBreakpointReturn {
102
107
  if (resizeTimerRef.current) {
103
108
  clearTimeout(resizeTimerRef.current);
104
109
  }
105
- resizeTimerRef.current = setTimeout(updateBreakpoint, 100);
110
+ resizeTimerRef.current = setTimeout(updateBreakpoint, TIMING.RESIZE_DEBOUNCE_MS);
106
111
  };
107
112
 
108
113
  window.addEventListener('resize', handleResize);