@umituz/web-design-system 3.1.22 → 4.0.0

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 (82) hide show
  1. package/README.md +137 -469
  2. package/dist/styles.css +2 -0
  3. package/package.json +30 -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/index.ts +12 -0
  14. package/src/presentation/atoms/IconButton.tsx +55 -0
  15. package/src/presentation/atoms/Progress.tsx +2 -1
  16. package/src/presentation/atoms/Spinner.tsx +1 -1
  17. package/src/presentation/atoms/Switch.tsx +2 -2
  18. package/src/presentation/atoms/Text.tsx +15 -13
  19. package/src/presentation/atoms/Tooltip.tsx +2 -1
  20. package/src/presentation/atoms/index.ts +3 -0
  21. package/src/presentation/hooks/index.ts +0 -3
  22. package/src/presentation/hooks/useClickOutside.ts +14 -10
  23. package/src/presentation/hooks/useClipboard.ts +30 -12
  24. package/src/presentation/hooks/useDebounce.ts +8 -10
  25. package/src/presentation/hooks/useLocalStorage.ts +28 -18
  26. package/src/presentation/hooks/useMediaQuery.ts +2 -1
  27. package/src/presentation/hooks/useScrollLock.ts +40 -12
  28. package/src/presentation/hooks/useToggle.ts +8 -6
  29. package/src/presentation/molecules/ActiveFilterTags.tsx +19 -18
  30. package/src/presentation/molecules/Avatar.tsx +1 -1
  31. package/src/presentation/molecules/CodeBlock.tsx +51 -48
  32. package/src/presentation/molecules/ListItem.tsx +40 -29
  33. package/src/presentation/molecules/Select.tsx +1 -1
  34. package/src/presentation/molecules/Toggle.tsx +4 -4
  35. package/src/presentation/molecules/index.ts +0 -3
  36. package/src/presentation/organisms/Alert.tsx +1 -1
  37. package/src/presentation/organisms/DataTable.tsx +24 -11
  38. package/src/presentation/organisms/Dialog.tsx +1 -1
  39. package/src/presentation/organisms/DropdownMenu.tsx +2 -2
  40. package/src/presentation/organisms/HoverCard.tsx +1 -1
  41. package/src/presentation/organisms/LoadingState.tsx +1 -5
  42. package/src/presentation/organisms/Popover.tsx +1 -1
  43. package/src/presentation/organisms/StatCard.tsx +2 -2
  44. package/src/presentation/organisms/Table.tsx +2 -2
  45. package/src/presentation/organisms/index.ts +0 -60
  46. package/src/styles/compile.css +18 -0
  47. package/src/styles/theme.css +131 -0
  48. package/src/domain/tokens/animation.tokens.ts +0 -87
  49. package/src/domain/tokens/color.tokens.ts +0 -95
  50. package/src/domain/tokens/radius.tokens.ts +0 -27
  51. package/src/domain/tokens/shadow.tokens.ts +0 -25
  52. package/src/domain/tokens/spacing.tokens.ts +0 -65
  53. package/src/domain/tokens/typography.tokens.ts +0 -74
  54. package/src/infrastructure/error/ErrorBoundary.tsx +0 -258
  55. package/src/infrastructure/error/ErrorDisplay.tsx +0 -346
  56. package/src/infrastructure/error/SuspenseWrapper.tsx +0 -133
  57. package/src/infrastructure/error/index.ts +0 -5
  58. package/src/infrastructure/performance/index.ts +0 -92
  59. package/src/infrastructure/performance/useLazyLoading.ts +0 -342
  60. package/src/infrastructure/performance/useMemoryOptimization.ts +0 -293
  61. package/src/infrastructure/performance/usePerformanceMonitor.ts +0 -158
  62. package/src/infrastructure/security/index.ts +0 -10
  63. package/src/infrastructure/security/security-config.ts +0 -171
  64. package/src/infrastructure/security/useFormValidation.ts +0 -216
  65. package/src/infrastructure/security/validation.ts +0 -246
  66. package/src/presentation/hooks/useLanguage.ts +0 -109
  67. package/src/presentation/molecules/Breadcrumb.tsx +0 -54
  68. package/src/presentation/organisms/AlertDialog.tsx +0 -181
  69. package/src/presentation/organisms/Calendar.tsx +0 -79
  70. package/src/presentation/organisms/Comments.tsx +0 -103
  71. package/src/presentation/organisms/ConfirmDialog.tsx +0 -97
  72. package/src/presentation/organisms/FilterBar.tsx +0 -244
  73. package/src/presentation/organisms/FilterSidebar.tsx +0 -172
  74. package/src/presentation/organisms/Footer.tsx +0 -86
  75. package/src/presentation/organisms/FormModal.tsx +0 -126
  76. package/src/presentation/organisms/Grid.tsx +0 -128
  77. package/src/presentation/organisms/ImageLightbox.tsx +0 -252
  78. package/src/presentation/organisms/MainNavbar.tsx +0 -320
  79. package/src/presentation/organisms/Modal.tsx +0 -173
  80. package/src/presentation/organisms/Navbar.tsx +0 -75
  81. package/src/presentation/organisms/NewsletterSignup.tsx +0 -165
  82. 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
+ };
@@ -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,30 @@ 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
+ callbackRef.current = callback;
13
17
 
14
18
  useEffect(() => {
15
19
  if (!enabled) return;
16
20
 
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();
21
+ const handlePointerDown = (event: Event) => {
22
+ const target = event.target;
23
+ if (!(target instanceof Node)) return;
24
+ if (ref.current && !ref.current.contains(target)) {
25
+ callbackRef.current();
22
26
  }
23
27
  };
24
28
 
25
- document.addEventListener('mousedown', handleClick);
26
- document.addEventListener('touchstart', handleClick);
29
+ document.addEventListener('mousedown', handlePointerDown);
30
+ document.addEventListener('touchstart', handlePointerDown);
27
31
 
28
32
  return () => {
29
- document.removeEventListener('mousedown', handleClick);
30
- document.removeEventListener('touchstart', handleClick);
33
+ document.removeEventListener('mousedown', handlePointerDown);
34
+ document.removeEventListener('touchstart', handlePointerDown);
31
35
  };
32
- }, [callback, enabled]);
36
+ }, [enabled]);
33
37
 
34
38
  return ref;
35
39
  }
@@ -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
  };
@@ -37,7 +36,7 @@ export function useDebounce<T>(value: T, delay: number = 500): T {
37
36
  */
38
37
  export function useThrottle<T extends (...args: any[]) => any>(
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,57 @@
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
6
  import { useCallback, useState, useRef } from 'react';
7
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
+ };
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
29
  valueRef.current = storedValue;
24
30
 
25
31
  const setValue = useCallback(
26
32
  (value: T | ((prev: T) => T)) => {
33
+ const next =
34
+ typeof value === 'function' ? (value as (prev: T) => T)(valueRef.current) : value;
35
+
36
+ setStoredValue(next);
37
+
38
+ if (!isBrowser()) return;
27
39
  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));
40
+ window.localStorage.setItem(key, JSON.stringify(next));
32
41
  } catch (error) {
33
- console.error(`Error setting localStorage key "${key}":`, error);
42
+ console.error(`[useLocalStorage] Failed to write key "${key}":`, error);
34
43
  }
35
44
  },
36
- [key] // Remove storedValue dependency
45
+ [key]
37
46
  );
38
47
 
39
48
  const removeValue = useCallback(() => {
49
+ setStoredValue(initialValue);
50
+ if (!isBrowser()) return;
40
51
  try {
41
52
  window.localStorage.removeItem(key);
42
- setStoredValue(initialValue);
43
53
  } catch (error) {
44
- console.error(`Error removing localStorage key "${key}":`, error);
54
+ console.error(`[useLocalStorage] Failed to remove key "${key}":`, error);
45
55
  }
46
56
  }, [key, initialValue]);
47
57
 
@@ -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
@@ -102,7 +103,7 @@ export function useBreakpoint(): UseBreakpointReturn {
102
103
  if (resizeTimerRef.current) {
103
104
  clearTimeout(resizeTimerRef.current);
104
105
  }
105
- resizeTimerRef.current = setTimeout(updateBreakpoint, 100);
106
+ resizeTimerRef.current = setTimeout(updateBreakpoint, TIMING.RESIZE_DEBOUNCE_MS);
106
107
  };
107
108
 
108
109
  window.addEventListener('resize', handleResize);
@@ -1,26 +1,54 @@
1
1
  /**
2
2
  * useScrollLock Hook
3
- * @description Lock/unlock body scroll
3
+ * @description Lock/unlock body scroll while preserving scrollbar gutter
4
4
  */
5
5
 
6
6
  import { useEffect } from 'react';
7
7
 
8
- export function useScrollLock(enabled: boolean = true) {
8
+ /** Width of the vertical scrollbar, so locking scroll doesn't shift layout. */
9
+ const measureScrollbarWidth = (): number => {
10
+ if (typeof window === 'undefined' || typeof document === 'undefined') return 0;
11
+ return Math.max(0, window.innerWidth - document.documentElement.clientWidth);
12
+ };
13
+
14
+ const SCROLLBAR_GUTTER_STYLE_ID = 'use-scroll-lock-gutter-style';
15
+
16
+ const ensureScrollbarGutterStyle = () => {
17
+ if (typeof document === 'undefined') return;
18
+ if (document.getElementById(SCROLLBAR_GUTTER_STYLE_ID)) return;
19
+
20
+ const style = document.createElement('style');
21
+ style.id = SCROLLBAR_GUTTER_STYLE_ID;
22
+ style.textContent = `
23
+ html.use-scroll-lock-active body {
24
+ overflow: hidden;
25
+ }
26
+ `;
27
+ document.head.appendChild(style);
28
+ };
29
+
30
+ export const useScrollLock = (enabled: boolean = true): void => {
9
31
  useEffect(() => {
10
- if (!enabled) return;
32
+ if (!enabled || typeof document === 'undefined') return;
11
33
 
12
- const originalStyle = window.getComputedStyle(document.body).overflow;
13
- const originalPaddingRight = window.getComputedStyle(document.body).paddingRight;
34
+ ensureScrollbarGutterStyle();
35
+ const html = document.documentElement;
36
+ const body = document.body;
14
37
 
15
- // FIX: Ensure scrollbar width is never negative
16
- const scrollbarWidth = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
38
+ const previousOverflow = body.style.overflow;
39
+ const previousPaddingRight = body.style.paddingRight;
40
+ const scrollbarWidth = measureScrollbarWidth();
17
41
 
18
- document.body.style.overflow = 'hidden';
19
- document.body.style.paddingRight = `${scrollbarWidth}px`;
42
+ body.style.overflow = 'hidden';
43
+ if (scrollbarWidth > 0) {
44
+ body.style.paddingRight = `${scrollbarWidth}px`;
45
+ }
46
+ html.classList.add('use-scroll-lock-active');
20
47
 
21
48
  return () => {
22
- document.body.style.overflow = originalStyle;
23
- document.body.style.paddingRight = originalPaddingRight;
49
+ body.style.overflow = previousOverflow;
50
+ body.style.paddingRight = previousPaddingRight;
51
+ html.classList.remove('use-scroll-lock-active');
24
52
  };
25
53
  }, [enabled]);
26
- }
54
+ };
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * useToggle Hook
3
- * @description Toggle boolean state
3
+ * @description Toggle boolean state with explicit setValue
4
4
  */
5
5
 
6
- import { useCallback, useState } from 'react';
6
+ import { useCallback, useState, useMemo } from 'react';
7
7
 
8
- export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
9
- const [value, setValue] = useState(initialValue);
8
+ export type UseToggleReturn = [boolean, () => void, (value: boolean) => void];
9
+
10
+ export function useToggle(initialValue: boolean = false): UseToggleReturn {
11
+ const [value, setValue] = useState<boolean>(initialValue);
10
12
 
11
13
  const toggle = useCallback(() => {
12
- setValue((v) => !v);
14
+ setValue((prev) => !prev);
13
15
  }, []);
14
16
 
15
- return [value, toggle, setValue];
17
+ return useMemo<UseToggleReturn>(() => [value, toggle, setValue], [value, toggle]);
16
18
  }