@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.
- package/README.md +137 -469
- package/dist/styles.css +2 -0
- package/package.json +30 -31
- package/src/__tests__/cn.test.ts +24 -0
- package/src/__tests__/components.smoke.test.tsx +78 -0
- package/src/__tests__/styles.contract.test.ts +60 -0
- package/src/domain/tokens/index.ts +8 -49
- package/src/index.ts +8 -10
- package/src/infrastructure/constants/component.constants.ts +3 -17
- package/src/infrastructure/constants/index.ts +4 -2
- package/src/infrastructure/constants/timing.constants.ts +46 -0
- package/src/infrastructure/utils/calculation.utils.ts +74 -0
- package/src/infrastructure/utils/index.ts +12 -0
- package/src/presentation/atoms/IconButton.tsx +55 -0
- package/src/presentation/atoms/Progress.tsx +2 -1
- package/src/presentation/atoms/Spinner.tsx +1 -1
- package/src/presentation/atoms/Switch.tsx +2 -2
- package/src/presentation/atoms/Text.tsx +15 -13
- package/src/presentation/atoms/Tooltip.tsx +2 -1
- package/src/presentation/atoms/index.ts +3 -0
- package/src/presentation/hooks/index.ts +0 -3
- package/src/presentation/hooks/useClickOutside.ts +14 -10
- package/src/presentation/hooks/useClipboard.ts +30 -12
- package/src/presentation/hooks/useDebounce.ts +8 -10
- package/src/presentation/hooks/useLocalStorage.ts +28 -18
- package/src/presentation/hooks/useMediaQuery.ts +2 -1
- package/src/presentation/hooks/useScrollLock.ts +40 -12
- package/src/presentation/hooks/useToggle.ts +8 -6
- package/src/presentation/molecules/ActiveFilterTags.tsx +19 -18
- package/src/presentation/molecules/Avatar.tsx +1 -1
- package/src/presentation/molecules/CodeBlock.tsx +51 -48
- package/src/presentation/molecules/ListItem.tsx +40 -29
- package/src/presentation/molecules/Select.tsx +1 -1
- package/src/presentation/molecules/Toggle.tsx +4 -4
- package/src/presentation/molecules/index.ts +0 -3
- package/src/presentation/organisms/Alert.tsx +1 -1
- package/src/presentation/organisms/DataTable.tsx +24 -11
- package/src/presentation/organisms/Dialog.tsx +1 -1
- package/src/presentation/organisms/DropdownMenu.tsx +2 -2
- package/src/presentation/organisms/HoverCard.tsx +1 -1
- package/src/presentation/organisms/LoadingState.tsx +1 -5
- package/src/presentation/organisms/Popover.tsx +1 -1
- package/src/presentation/organisms/StatCard.tsx +2 -2
- package/src/presentation/organisms/Table.tsx +2 -2
- package/src/presentation/organisms/index.ts +0 -60
- package/src/styles/compile.css +18 -0
- package/src/styles/theme.css +131 -0
- package/src/domain/tokens/animation.tokens.ts +0 -87
- package/src/domain/tokens/color.tokens.ts +0 -95
- package/src/domain/tokens/radius.tokens.ts +0 -27
- package/src/domain/tokens/shadow.tokens.ts +0 -25
- package/src/domain/tokens/spacing.tokens.ts +0 -65
- package/src/domain/tokens/typography.tokens.ts +0 -74
- package/src/infrastructure/error/ErrorBoundary.tsx +0 -258
- package/src/infrastructure/error/ErrorDisplay.tsx +0 -346
- package/src/infrastructure/error/SuspenseWrapper.tsx +0 -133
- package/src/infrastructure/error/index.ts +0 -5
- package/src/infrastructure/performance/index.ts +0 -92
- package/src/infrastructure/performance/useLazyLoading.ts +0 -342
- package/src/infrastructure/performance/useMemoryOptimization.ts +0 -293
- package/src/infrastructure/performance/usePerformanceMonitor.ts +0 -158
- package/src/infrastructure/security/index.ts +0 -10
- package/src/infrastructure/security/security-config.ts +0 -171
- package/src/infrastructure/security/useFormValidation.ts +0 -216
- package/src/infrastructure/security/validation.ts +0 -246
- package/src/presentation/hooks/useLanguage.ts +0 -109
- package/src/presentation/molecules/Breadcrumb.tsx +0 -54
- package/src/presentation/organisms/AlertDialog.tsx +0 -181
- package/src/presentation/organisms/Calendar.tsx +0 -79
- package/src/presentation/organisms/Comments.tsx +0 -103
- package/src/presentation/organisms/ConfirmDialog.tsx +0 -97
- package/src/presentation/organisms/FilterBar.tsx +0 -244
- package/src/presentation/organisms/FilterSidebar.tsx +0 -172
- package/src/presentation/organisms/Footer.tsx +0 -86
- package/src/presentation/organisms/FormModal.tsx +0 -126
- package/src/presentation/organisms/Grid.tsx +0 -128
- package/src/presentation/organisms/ImageLightbox.tsx +0 -252
- package/src/presentation/organisms/MainNavbar.tsx +0 -320
- package/src/presentation/organisms/Modal.tsx +0 -173
- package/src/presentation/organisms/Navbar.tsx +0 -75
- package/src/presentation/organisms/NewsletterSignup.tsx +0 -165
- 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 =
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
15
|
+
type TextComponentProps = {
|
|
16
16
|
as?: TextElement;
|
|
17
17
|
variant?: TextVariant;
|
|
18
18
|
size?: TextSize;
|
|
19
|
-
weight?:
|
|
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<
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
<
|
|
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 =
|
|
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
|
|
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (ref.current &&
|
|
21
|
-
|
|
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',
|
|
26
|
-
document.addEventListener('touchstart',
|
|
29
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
30
|
+
document.addEventListener('touchstart', handlePointerDown);
|
|
27
31
|
|
|
28
32
|
return () => {
|
|
29
|
-
document.removeEventListener('mousedown',
|
|
30
|
-
document.removeEventListener('touchstart',
|
|
33
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
34
|
+
document.removeEventListener('touchstart', handlePointerDown);
|
|
31
35
|
};
|
|
32
|
-
}, [
|
|
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<
|
|
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
|
-
|
|
39
|
+
if (timeoutRef.current !== null) {
|
|
40
|
+
clearTimeout(timeoutRef.current);
|
|
41
|
+
}
|
|
42
|
+
timeoutRef.current = setTimeout(() => {
|
|
25
43
|
setCopied(false);
|
|
26
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(`
|
|
42
|
+
console.error(`[useLocalStorage] Failed to write key "${key}":`, error);
|
|
34
43
|
}
|
|
35
44
|
},
|
|
36
|
-
[key]
|
|
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(`
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
const
|
|
34
|
+
ensureScrollbarGutterStyle();
|
|
35
|
+
const html = document.documentElement;
|
|
36
|
+
const body = document.body;
|
|
14
37
|
|
|
15
|
-
|
|
16
|
-
const
|
|
38
|
+
const previousOverflow = body.style.overflow;
|
|
39
|
+
const previousPaddingRight = body.style.paddingRight;
|
|
40
|
+
const scrollbarWidth = measureScrollbarWidth();
|
|
17
41
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
9
|
-
|
|
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((
|
|
14
|
+
setValue((prev) => !prev);
|
|
13
15
|
}, []);
|
|
14
16
|
|
|
15
|
-
return [value, toggle, setValue];
|
|
17
|
+
return useMemo<UseToggleReturn>(() => [value, toggle, setValue], [value, toggle]);
|
|
16
18
|
}
|