@umituz/web-design-system 2.7.2 → 2.8.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/package.json +1 -1
- package/src/domain/tokens/responsive-map.tokens.ts +243 -0
- package/src/infrastructure/utils/index.ts +10 -0
- package/src/infrastructure/utils/responsive.utils.ts +166 -0
- package/src/presentation/molecules/FormField.tsx +7 -6
- package/src/presentation/molecules/Select.tsx +1 -1
- package/src/presentation/organisms/EmptyState.tsx +8 -34
- package/src/presentation/organisms/Grid.tsx +129 -0
- package/src/presentation/organisms/LoadingState.tsx +18 -31
- package/src/presentation/organisms/MetricCard.tsx +28 -36
- package/src/presentation/organisms/QuickActionCard.tsx +15 -32
- package/src/presentation/organisms/StatCard.tsx +36 -54
- package/src/presentation/organisms/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive Design Tokens
|
|
3
|
+
* @description Centralized mapping of size variants to Tailwind spacing scale values
|
|
4
|
+
*
|
|
5
|
+
* Scale values align with Tailwind's default spacing scale:
|
|
6
|
+
* 3=12px, 4=16px, 5=20px, 6=24px, 8=32px, 10=40px, 12=48px, 16=64px, 20=80px
|
|
7
|
+
*
|
|
8
|
+
* All responsive calculations are centralized here - components should NOT
|
|
9
|
+
* define their own size styles or do any spacing calculations.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SizeVariant, Breakpoint } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spacing scale values for padding, margin, and gap
|
|
16
|
+
* Format: Tailwind spacing unit numbers (as strings for easier concatenation)
|
|
17
|
+
*/
|
|
18
|
+
export type SpacingScale = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '12' | '14' | '16' | '20' | '24';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Responsive spacing mapping by size variant and breakpoint
|
|
22
|
+
*/
|
|
23
|
+
export const RESPONSIVE_SPACING: Record<
|
|
24
|
+
SizeVariant,
|
|
25
|
+
Partial<Record<Breakpoint, SpacingScale>>
|
|
26
|
+
> = {
|
|
27
|
+
xs: {
|
|
28
|
+
xs: '2',
|
|
29
|
+
sm: '3',
|
|
30
|
+
md: '3',
|
|
31
|
+
lg: '4',
|
|
32
|
+
xl: '4',
|
|
33
|
+
'2xl': '5',
|
|
34
|
+
},
|
|
35
|
+
sm: {
|
|
36
|
+
xs: '3',
|
|
37
|
+
sm: '4',
|
|
38
|
+
md: '4',
|
|
39
|
+
lg: '5',
|
|
40
|
+
xl: '6',
|
|
41
|
+
'2xl': '8',
|
|
42
|
+
},
|
|
43
|
+
md: {
|
|
44
|
+
xs: '4',
|
|
45
|
+
sm: '5',
|
|
46
|
+
md: '5',
|
|
47
|
+
lg: '6',
|
|
48
|
+
xl: '8',
|
|
49
|
+
'2xl': '10',
|
|
50
|
+
},
|
|
51
|
+
lg: {
|
|
52
|
+
xs: '5',
|
|
53
|
+
sm: '6',
|
|
54
|
+
md: '6',
|
|
55
|
+
lg: '8',
|
|
56
|
+
xl: '10',
|
|
57
|
+
'2xl': '12',
|
|
58
|
+
},
|
|
59
|
+
xl: {
|
|
60
|
+
xs: '6',
|
|
61
|
+
sm: '8',
|
|
62
|
+
md: '8',
|
|
63
|
+
lg: '10',
|
|
64
|
+
xl: '12',
|
|
65
|
+
'2xl': '16',
|
|
66
|
+
},
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Icon size scale values (height/width)
|
|
71
|
+
*/
|
|
72
|
+
export const RESPONSIVE_ICON_SIZE: Record<
|
|
73
|
+
SizeVariant,
|
|
74
|
+
Partial<Record<Breakpoint, SpacingScale>>
|
|
75
|
+
> = {
|
|
76
|
+
xs: {
|
|
77
|
+
xs: '3',
|
|
78
|
+
sm: '4',
|
|
79
|
+
md: '5',
|
|
80
|
+
lg: '5',
|
|
81
|
+
xl: '6',
|
|
82
|
+
'2xl': '7',
|
|
83
|
+
},
|
|
84
|
+
sm: {
|
|
85
|
+
xs: '4',
|
|
86
|
+
sm: '5',
|
|
87
|
+
md: '6',
|
|
88
|
+
lg: '6',
|
|
89
|
+
xl: '8',
|
|
90
|
+
'2xl': '8',
|
|
91
|
+
},
|
|
92
|
+
md: {
|
|
93
|
+
xs: '5',
|
|
94
|
+
sm: '6',
|
|
95
|
+
md: '7',
|
|
96
|
+
lg: '8',
|
|
97
|
+
xl: '9',
|
|
98
|
+
'2xl': '10',
|
|
99
|
+
},
|
|
100
|
+
lg: {
|
|
101
|
+
xs: '6',
|
|
102
|
+
sm: '7',
|
|
103
|
+
md: '8',
|
|
104
|
+
lg: '9',
|
|
105
|
+
xl: '10',
|
|
106
|
+
'2xl': '12',
|
|
107
|
+
},
|
|
108
|
+
xl: {
|
|
109
|
+
xs: '8',
|
|
110
|
+
sm: '9',
|
|
111
|
+
md: '10',
|
|
112
|
+
lg: '12',
|
|
113
|
+
xl: '14',
|
|
114
|
+
'2xl': '16',
|
|
115
|
+
},
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Container/icon wrapper size (for rounded containers that hold icons)
|
|
120
|
+
*/
|
|
121
|
+
export const RESPONSIVE_CONTAINER_SIZE: Record<
|
|
122
|
+
SizeVariant,
|
|
123
|
+
Partial<Record<Breakpoint, SpacingScale>>
|
|
124
|
+
> = {
|
|
125
|
+
xs: {
|
|
126
|
+
xs: '6',
|
|
127
|
+
sm: '8',
|
|
128
|
+
md: '8',
|
|
129
|
+
lg: '10',
|
|
130
|
+
xl: '10',
|
|
131
|
+
'2xl': '12',
|
|
132
|
+
},
|
|
133
|
+
sm: {
|
|
134
|
+
xs: '8',
|
|
135
|
+
sm: '10',
|
|
136
|
+
md: '10',
|
|
137
|
+
lg: '12',
|
|
138
|
+
xl: '12',
|
|
139
|
+
'2xl': '14',
|
|
140
|
+
},
|
|
141
|
+
md: {
|
|
142
|
+
xs: '10',
|
|
143
|
+
sm: '12',
|
|
144
|
+
md: '12',
|
|
145
|
+
lg: '14',
|
|
146
|
+
xl: '14',
|
|
147
|
+
'2xl': '16',
|
|
148
|
+
},
|
|
149
|
+
lg: {
|
|
150
|
+
xs: '12',
|
|
151
|
+
sm: '14',
|
|
152
|
+
md: '14',
|
|
153
|
+
lg: '16',
|
|
154
|
+
xl: '16',
|
|
155
|
+
'2xl': '20',
|
|
156
|
+
},
|
|
157
|
+
xl: {
|
|
158
|
+
xs: '14',
|
|
159
|
+
sm: '16',
|
|
160
|
+
md: '16',
|
|
161
|
+
lg: '20',
|
|
162
|
+
xl: '20',
|
|
163
|
+
'2xl': '24',
|
|
164
|
+
},
|
|
165
|
+
} as const;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Text size scale (Tailwind text utilities)
|
|
169
|
+
*/
|
|
170
|
+
export const RESPONSIVE_TEXT_SIZE: Record<
|
|
171
|
+
SizeVariant,
|
|
172
|
+
Partial<Record<'mobile' | 'tablet', string>>
|
|
173
|
+
> = {
|
|
174
|
+
xs: {
|
|
175
|
+
mobile: 'text-xs',
|
|
176
|
+
tablet: 'sm:text-xs',
|
|
177
|
+
},
|
|
178
|
+
sm: {
|
|
179
|
+
mobile: 'text-xs',
|
|
180
|
+
tablet: 'sm:text-sm',
|
|
181
|
+
},
|
|
182
|
+
md: {
|
|
183
|
+
mobile: 'text-sm',
|
|
184
|
+
tablet: 'sm:text-base',
|
|
185
|
+
},
|
|
186
|
+
lg: {
|
|
187
|
+
mobile: 'text-base',
|
|
188
|
+
tablet: 'sm:text-lg',
|
|
189
|
+
},
|
|
190
|
+
xl: {
|
|
191
|
+
mobile: 'text-lg',
|
|
192
|
+
tablet: 'sm:text-xl',
|
|
193
|
+
},
|
|
194
|
+
} as const;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gap/Spacing between elements (space-y-*, space-x-*, gap-*)
|
|
198
|
+
*/
|
|
199
|
+
export const RESPONSIVE_GAP: Record<
|
|
200
|
+
SizeVariant,
|
|
201
|
+
Partial<Record<Breakpoint, SpacingScale>>
|
|
202
|
+
> = {
|
|
203
|
+
xs: {
|
|
204
|
+
xs: '1',
|
|
205
|
+
sm: '2',
|
|
206
|
+
md: '2',
|
|
207
|
+
lg: '3',
|
|
208
|
+
xl: '3',
|
|
209
|
+
'2xl': '4',
|
|
210
|
+
},
|
|
211
|
+
sm: {
|
|
212
|
+
xs: '2',
|
|
213
|
+
sm: '3',
|
|
214
|
+
md: '3',
|
|
215
|
+
lg: '4',
|
|
216
|
+
xl: '4',
|
|
217
|
+
'2xl': '5',
|
|
218
|
+
},
|
|
219
|
+
md: {
|
|
220
|
+
xs: '3',
|
|
221
|
+
sm: '4',
|
|
222
|
+
md: '4',
|
|
223
|
+
lg: '5',
|
|
224
|
+
xl: '5',
|
|
225
|
+
'2xl': '6',
|
|
226
|
+
},
|
|
227
|
+
lg: {
|
|
228
|
+
xs: '4',
|
|
229
|
+
sm: '5',
|
|
230
|
+
md: '5',
|
|
231
|
+
lg: '6',
|
|
232
|
+
xl: '6',
|
|
233
|
+
'2xl': '8',
|
|
234
|
+
},
|
|
235
|
+
xl: {
|
|
236
|
+
xs: '5',
|
|
237
|
+
sm: '6',
|
|
238
|
+
md: '6',
|
|
239
|
+
lg: '8',
|
|
240
|
+
xl: '8',
|
|
241
|
+
'2xl': '10',
|
|
242
|
+
},
|
|
243
|
+
} as const;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive Utility Functions
|
|
3
|
+
* @description Centralized functions for building responsive Tailwind classes
|
|
4
|
+
* All responsive class construction logic lives here - components should only call these functions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { cn } from './cn.util';
|
|
8
|
+
import type { SizeVariant, Breakpoint } from '../../domain/types';
|
|
9
|
+
import {
|
|
10
|
+
RESPONSIVE_SPACING,
|
|
11
|
+
RESPONSIVE_ICON_SIZE,
|
|
12
|
+
RESPONSIVE_CONTAINER_SIZE,
|
|
13
|
+
RESPONSIVE_TEXT_SIZE,
|
|
14
|
+
RESPONSIVE_GAP,
|
|
15
|
+
} from '../../domain/tokens/responsive-map.tokens';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build responsive padding/margin/gap classes
|
|
19
|
+
* @param property - Tailwind spacing property ('p', 'm', 'px', 'py', 'gap', 'space-y', 'space-x')
|
|
20
|
+
* @param size - Size variant (sm, md, lg, xl)
|
|
21
|
+
* @returns Complete responsive class string
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* getSpacing('p', 'md') // "p-4 sm:p-5 md:p-5 lg:p-6 xl:p-8 2xl:p-10"
|
|
25
|
+
*/
|
|
26
|
+
export function getSpacing(
|
|
27
|
+
property: 'p' | 'm' | 'px' | 'py' | 'pt' | 'pb' | 'pl' | 'pr' | 'gap' | 'space-y' | 'space-x',
|
|
28
|
+
size: SizeVariant
|
|
29
|
+
): string {
|
|
30
|
+
const token = RESPONSIVE_SPACING[size];
|
|
31
|
+
|
|
32
|
+
// Start with base (xs/mobile) value
|
|
33
|
+
const classes: string[] = [`${property}-${token.xs}`];
|
|
34
|
+
|
|
35
|
+
// Add breakpoint-specific classes
|
|
36
|
+
const breakpoints: Breakpoint[] = ['sm', 'md', 'lg', 'xl', '2xl'];
|
|
37
|
+
breakpoints.forEach((bp) => {
|
|
38
|
+
if (token[bp] !== token.xs) {
|
|
39
|
+
classes.push(`${bp}:${property}-${token[bp]}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return classes.join(' ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build responsive icon size classes (height + width)
|
|
48
|
+
* @param size - Size variant
|
|
49
|
+
* @returns Combined height and width classes
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* getIconSize('md') // "h-5 w-5 sm:h-6 sm:w-6 md:h-7 md:w-7 lg:h-8 lg:w-8 xl:h-9 xl:w-9 2xl:h-10 2xl:w-10"
|
|
53
|
+
*/
|
|
54
|
+
export function getIconSize(size: SizeVariant): string {
|
|
55
|
+
const token = RESPONSIVE_ICON_SIZE[size];
|
|
56
|
+
const classes: string[] = [`h-${token.xs}`, `w-${token.xs}`];
|
|
57
|
+
|
|
58
|
+
const breakpoints: Breakpoint[] = ['sm', 'md', 'lg', 'xl', '2xl'];
|
|
59
|
+
breakpoints.forEach((bp) => {
|
|
60
|
+
if (token[bp] !== token.xs) {
|
|
61
|
+
classes.push(`${bp}:h-${token[bp]}`, `${bp}:w-${token[bp]}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return classes.join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build responsive container size classes (for icon wrappers)
|
|
70
|
+
* @param size - Size variant
|
|
71
|
+
* @returns Combined height, width, and flex center classes
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* getContainerSize('md') // "w-10 h-10 sm:w-12 sm:h-12 md:w-12 md:h-12..."
|
|
75
|
+
*/
|
|
76
|
+
export function getContainerSize(size: SizeVariant): string {
|
|
77
|
+
const token = RESPONSIVE_CONTAINER_SIZE[size];
|
|
78
|
+
const classes: string[] = [`w-${token.xs}`, `h-${token.xs}`];
|
|
79
|
+
|
|
80
|
+
const breakpoints: Breakpoint[] = ['sm', 'md', 'lg', 'xl', '2xl'];
|
|
81
|
+
breakpoints.forEach((bp) => {
|
|
82
|
+
if (token[bp] !== token.xs) {
|
|
83
|
+
classes.push(`${bp}:w-${token[bp]}`, `${bp}:h-${token[bp]}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return classes.join(' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get responsive text size classes
|
|
92
|
+
* @param size - Size variant
|
|
93
|
+
* @returns Text size class string
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* getTextSize('md') // "text-sm sm:text-base"
|
|
97
|
+
*/
|
|
98
|
+
export function getTextSize(size: SizeVariant): string {
|
|
99
|
+
const token = RESPONSIVE_TEXT_SIZE[size];
|
|
100
|
+
return cn(token.mobile, token.tablet);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build responsive gap/spacing classes for layout
|
|
105
|
+
* @param size - Size variant
|
|
106
|
+
* @returns Gap class string
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* getGap('md') // "gap-3 sm:gap-4 md:gap-4 lg:gap-5 xl:gap-5 2xl:gap-6"
|
|
110
|
+
*/
|
|
111
|
+
export function getGap(size: SizeVariant): string {
|
|
112
|
+
return getSpacing('gap', size);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build responsive space-y classes for vertical stacking
|
|
117
|
+
* @param size - Size variant
|
|
118
|
+
* @returns Space-y class string
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* getSpaceY('md') // "space-y-3 sm:space-y-4 md:space-y-4 lg:space-y-5..."
|
|
122
|
+
*/
|
|
123
|
+
export function getSpaceY(size: SizeVariant): string {
|
|
124
|
+
return getSpacing('space-y', size);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get complete responsive styles for common component patterns
|
|
129
|
+
* @param size - Size variant
|
|
130
|
+
* @param options - Which styles to include
|
|
131
|
+
* @returns Combined class string
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* getResponsiveStyles('md', { padding: true, gap: true })
|
|
135
|
+
* // "p-4 sm:p-5 md:p-5 lg:p-6 xl:p-8 2xl:p-10 gap-3 sm:gap-4..."
|
|
136
|
+
*/
|
|
137
|
+
export function getResponsiveStyles(
|
|
138
|
+
size: SizeVariant,
|
|
139
|
+
options: {
|
|
140
|
+
padding?: boolean;
|
|
141
|
+
gap?: boolean;
|
|
142
|
+
iconSize?: boolean;
|
|
143
|
+
textSize?: boolean;
|
|
144
|
+
containerSize?: boolean;
|
|
145
|
+
} = {}
|
|
146
|
+
): string {
|
|
147
|
+
const classes: string[] = [];
|
|
148
|
+
|
|
149
|
+
if (options.padding) {
|
|
150
|
+
classes.push(getSpacing('p', size));
|
|
151
|
+
}
|
|
152
|
+
if (options.gap) {
|
|
153
|
+
classes.push(getGap(size));
|
|
154
|
+
}
|
|
155
|
+
if (options.iconSize) {
|
|
156
|
+
classes.push(getIconSize(size));
|
|
157
|
+
}
|
|
158
|
+
if (options.textSize) {
|
|
159
|
+
classes.push(getTextSize(size));
|
|
160
|
+
}
|
|
161
|
+
if (options.containerSize) {
|
|
162
|
+
classes.push(getContainerSize(size));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return classes.join(' ');
|
|
166
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FormField Component (Molecule)
|
|
3
|
-
* @description Label + Input combination
|
|
3
|
+
* @description Label + Input combination (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { forwardRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import { cn, getSpaceY } from '../../infrastructure/utils';
|
|
8
8
|
import type { BaseProps } from '../../domain/types';
|
|
9
9
|
import { Input } from '../atoms/Input';
|
|
10
10
|
import { Text } from '../atoms/Text';
|
|
@@ -16,20 +16,21 @@ export interface FormFieldProps extends BaseProps {
|
|
|
16
16
|
required?: boolean;
|
|
17
17
|
id?: string;
|
|
18
18
|
inputProps?: React.ComponentProps<typeof Input>;
|
|
19
|
+
fullWidth?: boolean; // Enable responsive full width
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
|
22
|
-
({ label, error, helperText, required, id, className, inputProps, ...props }, ref) => {
|
|
23
|
+
({ label, error, helperText, required, id, className, inputProps, fullWidth = true, ...props }, ref) => {
|
|
23
24
|
const fieldId = id || inputProps?.id || inputProps?.name;
|
|
24
25
|
const errorId = error ? `${fieldId}-error` : undefined;
|
|
25
26
|
const helperId = helperText ? `${fieldId}-helper` : undefined;
|
|
26
27
|
|
|
27
28
|
return (
|
|
28
|
-
<div className={cn('
|
|
29
|
+
<div className={cn(getSpaceY('sm'), fullWidth && 'w-full', className)} {...props}>
|
|
29
30
|
{label && (
|
|
30
|
-
<label htmlFor={fieldId} className="text-sm font-medium">
|
|
31
|
+
<label htmlFor={fieldId} className="text-sm font-medium block">
|
|
31
32
|
{label}
|
|
32
|
-
{required && <span className="text-destructive ml-1">*</span>}
|
|
33
|
+
{required && <span className="text-destructive ml-1" aria-label="required">*</span>}
|
|
33
34
|
</label>
|
|
34
35
|
)}
|
|
35
36
|
|
|
@@ -21,7 +21,7 @@ const SelectTrigger = React.forwardRef<
|
|
|
21
21
|
<SelectPrimitive.Trigger
|
|
22
22
|
ref={ref}
|
|
23
23
|
className={cn(
|
|
24
|
-
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
|
24
|
+
'flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
|
25
25
|
className
|
|
26
26
|
)}
|
|
27
27
|
{...props}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EmptyState Component (Organism)
|
|
3
|
-
* @description Component for displaying empty states with optional actions
|
|
3
|
+
* @description Component for displaying empty states with optional actions (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { forwardRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import { cn, getSpacing, getGap } from '../../infrastructure/utils';
|
|
8
8
|
import type { BaseProps, SizeVariant } from '../../domain/types';
|
|
9
9
|
|
|
10
10
|
export interface EmptyStateProps extends BaseProps {
|
|
@@ -16,36 +16,9 @@ export interface EmptyStateProps extends BaseProps {
|
|
|
16
16
|
onClick: () => void;
|
|
17
17
|
};
|
|
18
18
|
size?: Extract<SizeVariant, 'sm' | 'md' | 'lg' | 'xl'>;
|
|
19
|
+
centered?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const sizeStyles = {
|
|
22
|
-
sm: 'p-6 gap-3',
|
|
23
|
-
md: 'p-8 gap-4',
|
|
24
|
-
lg: 'p-12 gap-5',
|
|
25
|
-
xl: 'p-16 gap-6',
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const iconSizeStyles = {
|
|
29
|
-
sm: 'h-8 w-8',
|
|
30
|
-
md: 'h-12 w-12',
|
|
31
|
-
lg: 'h-16 w-16',
|
|
32
|
-
xl: 'h-20 w-20',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const titleSizeStyles = {
|
|
36
|
-
sm: 'text-sm',
|
|
37
|
-
md: 'text-base',
|
|
38
|
-
lg: 'text-lg',
|
|
39
|
-
xl: 'text-xl',
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const descriptionSizeStyles = {
|
|
43
|
-
sm: 'text-xs',
|
|
44
|
-
md: 'text-sm',
|
|
45
|
-
lg: 'text-base',
|
|
46
|
-
xl: 'text-lg',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
22
|
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
50
23
|
(
|
|
51
24
|
{
|
|
@@ -64,17 +37,18 @@ export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
|
64
37
|
ref={ref}
|
|
65
38
|
className={cn(
|
|
66
39
|
'flex flex-col items-center justify-center text-center',
|
|
67
|
-
|
|
40
|
+
getSpacing('p', size),
|
|
41
|
+
getGap(size === 'xl' ? 'lg' : size),
|
|
68
42
|
className
|
|
69
43
|
)}
|
|
70
44
|
{...props}
|
|
71
45
|
>
|
|
72
46
|
{Icon && (
|
|
73
|
-
<Icon className={cn(
|
|
47
|
+
<Icon className={cn(size === 'sm' ? 'h-8 w-8 sm:h-10 sm:w-10' : size === 'md' ? 'h-12 w-12 sm:h-14 sm:w-14' : size === 'lg' ? 'h-16 w-16 sm:h-20 sm:w-20' : 'h-20 w-20 sm:h-24 sm:w-24', 'text-muted-foreground/50 mx-auto mb-2')} />
|
|
74
48
|
)}
|
|
75
|
-
<h3 className={cn('font-semibold text-foreground',
|
|
49
|
+
<h3 className={cn('font-semibold text-foreground', size === 'sm' ? 'text-sm sm:text-base' : size === 'md' ? 'text-base sm:text-lg' : size === 'lg' ? 'text-lg sm:text-xl' : 'text-xl sm:text-2xl')}>{title}</h3>
|
|
76
50
|
{description && (
|
|
77
|
-
<p className={cn('text-muted-foreground max-w-md',
|
|
51
|
+
<p className={cn('text-muted-foreground max-w-md', size === 'sm' ? 'text-xs sm:text-sm' : size === 'md' ? 'text-sm sm:text-base' : size === 'lg' ? 'text-base sm:text-lg' : 'text-lg sm:text-xl')}>
|
|
78
52
|
{description}
|
|
79
53
|
</p>
|
|
80
54
|
)}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid Component (Organism)
|
|
3
|
+
* @description Responsive grid system with configurable columns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import type { BaseProps } from '../../domain/types';
|
|
8
|
+
import type { Breakpoint } from '../../domain/types/breakpoint.types';
|
|
9
|
+
|
|
10
|
+
export interface GridProps extends BaseProps {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
cols?: {
|
|
13
|
+
xs?: number;
|
|
14
|
+
sm?: number;
|
|
15
|
+
md?: number;
|
|
16
|
+
lg?: number;
|
|
17
|
+
xl?: number;
|
|
18
|
+
'2xl'?: number;
|
|
19
|
+
};
|
|
20
|
+
gap?: number | string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Responsive grid system that adjusts columns based on breakpoint
|
|
26
|
+
* @param cols - Number of columns per breakpoint
|
|
27
|
+
* @param gap - Gap between items (spacing unit or custom value)
|
|
28
|
+
* @param children - Grid items
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* // Auto responsive: 1 col mobile, 2 tablet, 3 desktop
|
|
33
|
+
* <Grid cols={{ xs: 1, sm: 2, lg: 3 }}>
|
|
34
|
+
* <div>Item 1</div>
|
|
35
|
+
* <div>Item 2</div>
|
|
36
|
+
* </Grid>
|
|
37
|
+
*
|
|
38
|
+
* // Fixed 4 columns
|
|
39
|
+
* <Grid cols={{ lg: 4 }}>
|
|
40
|
+
* {items.map(item => <Card key={item.id}>{item}</Card>)}
|
|
41
|
+
* </Grid>
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export const Grid = ({ children, cols = {}, gap = 4, className }: GridProps) => {
|
|
45
|
+
const {
|
|
46
|
+
xs = 1,
|
|
47
|
+
sm = xs,
|
|
48
|
+
md = sm,
|
|
49
|
+
lg = md,
|
|
50
|
+
xl = lg,
|
|
51
|
+
'2xl': xl2 = xl
|
|
52
|
+
} = cols;
|
|
53
|
+
|
|
54
|
+
const gridCols = cn(
|
|
55
|
+
'grid',
|
|
56
|
+
`grid-cols-${xs}`, // xs always applies (mobile-first)
|
|
57
|
+
sm !== xs && `sm:grid-cols-${sm}`,
|
|
58
|
+
md !== sm && `md:grid-cols-${md}`,
|
|
59
|
+
lg !== md && `lg:grid-cols-${lg}`,
|
|
60
|
+
xl !== lg && `xl:grid-cols-${xl}`,
|
|
61
|
+
xl2 !== xl && `2xl:grid-cols-${xl2}`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn(gridCols, gapClass, className)}>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
Grid.displayName = 'Grid';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Grid Item component for custom column spans
|
|
77
|
+
*/
|
|
78
|
+
export interface GridItemProps extends BaseProps {
|
|
79
|
+
children: React.ReactNode;
|
|
80
|
+
span?: {
|
|
81
|
+
xs?: number;
|
|
82
|
+
sm?: number;
|
|
83
|
+
md?: number;
|
|
84
|
+
lg?: number;
|
|
85
|
+
xl?: number;
|
|
86
|
+
'2xl'?: number;
|
|
87
|
+
};
|
|
88
|
+
className?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Grid item with custom column span per breakpoint
|
|
93
|
+
* @param span - Number of columns to span (1-12)
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* <Grid cols={{ lg: 4 }}>
|
|
98
|
+
* <GridItem span={{ lg: 2 }}>Spans 2 columns</GridItem>
|
|
99
|
+
* <GridItem>Regular 1 column</GridItem>
|
|
100
|
+
* </Grid>
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const GridItem = ({ children, span = {}, className }: GridItemProps) => {
|
|
104
|
+
const {
|
|
105
|
+
xs = 1,
|
|
106
|
+
sm = xs,
|
|
107
|
+
md = sm,
|
|
108
|
+
lg = md,
|
|
109
|
+
xl = lg,
|
|
110
|
+
'2xl': xl2 = xl
|
|
111
|
+
} = span;
|
|
112
|
+
|
|
113
|
+
const colSpan = cn(
|
|
114
|
+
'col-span-1',
|
|
115
|
+
`sm:col-span-${sm}`,
|
|
116
|
+
`md:col-span-${md}`,
|
|
117
|
+
`lg:col-span-${lg}`,
|
|
118
|
+
`xl:col-span-${xl}`,
|
|
119
|
+
`2xl:col-span-${xl2}`
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={cn(colSpan, className)}>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
GridItem.displayName = 'GridItem';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LoadingState Component (Organism)
|
|
3
|
-
* @description Component for displaying loading states
|
|
3
|
+
* @description Component for displaying loading states (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { forwardRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import { cn, getSpacing, getGap } from '../../infrastructure/utils';
|
|
8
8
|
import { Spinner } from '../atoms/Spinner';
|
|
9
9
|
import type { BaseProps, SizeVariant } from '../../domain/types';
|
|
10
10
|
|
|
@@ -12,26 +12,9 @@ export interface LoadingStateProps extends BaseProps {
|
|
|
12
12
|
message?: string;
|
|
13
13
|
size?: Extract<SizeVariant, 'sm' | 'md' | 'lg'>;
|
|
14
14
|
variant?: 'spinner' | 'skeleton' | 'dots';
|
|
15
|
+
centered?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const sizeStyles = {
|
|
18
|
-
sm: 'gap-3',
|
|
19
|
-
md: 'gap-4',
|
|
20
|
-
lg: 'gap-5',
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const spinnerSizeStyles = {
|
|
24
|
-
sm: 'sm' as const,
|
|
25
|
-
md: 'md' as const,
|
|
26
|
-
lg: 'lg' as const,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const messageSizeStyles = {
|
|
30
|
-
sm: 'text-xs',
|
|
31
|
-
md: 'text-sm',
|
|
32
|
-
lg: 'text-base',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
18
|
export const LoadingState = forwardRef<HTMLDivElement, LoadingStateProps>(
|
|
36
19
|
(
|
|
37
20
|
{
|
|
@@ -39,30 +22,34 @@ export const LoadingState = forwardRef<HTMLDivElement, LoadingStateProps>(
|
|
|
39
22
|
message,
|
|
40
23
|
size = 'md',
|
|
41
24
|
variant = 'spinner',
|
|
25
|
+
centered = true,
|
|
42
26
|
...props
|
|
43
27
|
},
|
|
44
28
|
ref
|
|
45
29
|
) => {
|
|
30
|
+
const spinnerSize: 'sm' | 'md' | 'lg' = size;
|
|
46
31
|
return (
|
|
47
32
|
<div
|
|
48
33
|
ref={ref}
|
|
49
34
|
className={cn(
|
|
50
|
-
'flex flex-col items-center justify-center
|
|
51
|
-
|
|
35
|
+
'flex flex-col items-center justify-center',
|
|
36
|
+
getGap(size),
|
|
37
|
+
getSpacing('p', size),
|
|
38
|
+
centered && 'min-h-[200px] sm:min-h-[300px]',
|
|
52
39
|
className
|
|
53
40
|
)}
|
|
54
41
|
{...props}
|
|
55
42
|
>
|
|
56
|
-
{variant === 'spinner' && <Spinner size={
|
|
43
|
+
{variant === 'spinner' && <Spinner size={spinnerSize} />}
|
|
57
44
|
{variant === 'dots' && (
|
|
58
|
-
<div className="flex gap-1">
|
|
45
|
+
<div className="flex gap-1 sm:gap-2">
|
|
59
46
|
{[0, 1, 2].map((i) => (
|
|
60
47
|
<div
|
|
61
48
|
key={i}
|
|
62
49
|
className={cn(
|
|
63
50
|
'w-2 h-2 bg-primary rounded-full animate-bounce',
|
|
64
|
-
|
|
65
|
-
|
|
51
|
+
'w-1.5 h-1.5 sm:w-2 sm:h-2',
|
|
52
|
+
'lg:w-2.5 h-2.5'
|
|
66
53
|
)}
|
|
67
54
|
style={{ animationDelay: `${i * 0.15}s` }}
|
|
68
55
|
/>
|
|
@@ -70,14 +57,14 @@ export const LoadingState = forwardRef<HTMLDivElement, LoadingStateProps>(
|
|
|
70
57
|
</div>
|
|
71
58
|
)}
|
|
72
59
|
{variant === 'skeleton' && (
|
|
73
|
-
<div className="w-full space-y-3">
|
|
74
|
-
<div className="h-4 bg-muted animate-pulse rounded
|
|
75
|
-
<div className="h-3 bg-muted animate-pulse rounded"
|
|
76
|
-
<div className="h-3 bg-muted animate-pulse rounded"
|
|
60
|
+
<div className="w-full space-y-2 sm:space-y-3">
|
|
61
|
+
<div className="h-3 sm:h-4 bg-muted animate-pulse rounded w-2/3 sm:w-1/2" />
|
|
62
|
+
<div className="h-2.5 sm:h-3 bg-muted animate-pulse rounded w-full" />
|
|
63
|
+
<div className="h-2.5 sm:h-3 bg-muted animate-pulse rounded w-3/4" />
|
|
77
64
|
</div>
|
|
78
65
|
)}
|
|
79
66
|
{message && variant !== 'skeleton' && (
|
|
80
|
-
<p className={cn('text-muted-foreground mt-2',
|
|
67
|
+
<p className={cn('text-muted-foreground mt-2 sm:mt-3', size === 'sm' ? 'text-xs sm:text-sm' : size === 'md' ? 'text-sm sm:text-base' : 'text-base sm:text-lg', 'text-center max-w-md')}>{message}</p>
|
|
81
68
|
)}
|
|
82
69
|
</div>
|
|
83
70
|
);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MetricCard Component (Organism)
|
|
3
|
-
* @description Card component for displaying metrics and statistics with trend indicators
|
|
3
|
+
* @description Card component for displaying metrics and statistics with trend indicators (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { forwardRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import { cn, getSpacing, getIconSize, getTextSize, getGap } from '../../infrastructure/utils';
|
|
8
8
|
import { Card, CardContent } from './Card';
|
|
9
9
|
import type { BaseProps, ColorVariant, SizeVariant } from '../../domain/types';
|
|
10
10
|
|
|
@@ -21,32 +21,9 @@ export interface MetricCardProps extends BaseProps {
|
|
|
21
21
|
variant?: ColorVariant;
|
|
22
22
|
trendValueFormatter?: (value: number) => string;
|
|
23
23
|
onClick?: () => void;
|
|
24
|
+
responsiveLayout?: boolean; // Enable responsive layout changes
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const sizeStyles = {
|
|
27
|
-
sm: 'p-3',
|
|
28
|
-
md: 'p-4',
|
|
29
|
-
lg: 'p-5',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const valueSizeStyles = {
|
|
33
|
-
sm: 'text-lg',
|
|
34
|
-
md: 'text-2xl',
|
|
35
|
-
lg: 'text-3xl',
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const iconSizeStyles = {
|
|
39
|
-
sm: 'h-4 w-4',
|
|
40
|
-
md: 'h-8 w-8',
|
|
41
|
-
lg: 'h-10 w-10',
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const labelSizeStyles = {
|
|
45
|
-
sm: 'text-xs',
|
|
46
|
-
md: 'text-sm',
|
|
47
|
-
lg: 'text-base',
|
|
48
|
-
};
|
|
49
|
-
|
|
50
27
|
const trendColors: Record<ColorVariant, { positive: string; negative: string; bg: string }> = {
|
|
51
28
|
primary: { positive: 'text-primary', negative: 'text-destructive', bg: 'bg-primary' },
|
|
52
29
|
secondary: { positive: 'text-secondary', negative: 'text-destructive', bg: 'bg-secondary' },
|
|
@@ -68,6 +45,7 @@ export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
|
|
68
45
|
variant = 'primary',
|
|
69
46
|
trendValueFormatter = (v) => `${v > 0 ? '+' : ''}${v}%`,
|
|
70
47
|
onClick,
|
|
48
|
+
responsiveLayout = true,
|
|
71
49
|
...props
|
|
72
50
|
},
|
|
73
51
|
ref
|
|
@@ -87,34 +65,48 @@ export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(
|
|
|
87
65
|
onClick={onClick}
|
|
88
66
|
{...props}
|
|
89
67
|
>
|
|
90
|
-
<CardContent className={
|
|
91
|
-
<div className=
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
68
|
+
<CardContent className={getSpacing('p', size)}>
|
|
69
|
+
<div className={cn(
|
|
70
|
+
'flex items-center justify-between',
|
|
71
|
+
getGap('md'),
|
|
72
|
+
responsiveLayout && 'flex-col sm:flex-row'
|
|
73
|
+
)}>
|
|
74
|
+
<div className={cn(
|
|
75
|
+
'flex-1',
|
|
76
|
+
responsiveLayout && 'text-center sm:text-left w-full sm:w-auto'
|
|
77
|
+
)}>
|
|
78
|
+
<p className={cn('text-muted-foreground font-medium', getTextSize(size))}>{title}</p>
|
|
79
|
+
<p className={cn('font-bold text-foreground mt-1', size === 'sm' ? 'text-lg sm:text-xl' : size === 'md' ? 'text-2xl' : 'text-3xl sm:text-4xl')}>
|
|
95
80
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
|
96
81
|
</p>
|
|
97
82
|
{trend && (
|
|
98
|
-
<div className=
|
|
83
|
+
<div className={cn(
|
|
84
|
+
'flex items-center gap-1 mt-1',
|
|
85
|
+
responsiveLayout ? 'justify-center sm:justify-start' : ''
|
|
86
|
+
)}>
|
|
99
87
|
<span
|
|
100
88
|
className={cn(
|
|
101
|
-
'text-xs font-medium',
|
|
89
|
+
'text-xs sm:text-sm font-medium',
|
|
102
90
|
isPositive ? colors.positive : colors.negative
|
|
103
91
|
)}
|
|
104
92
|
>
|
|
105
93
|
{TrendIcon}
|
|
106
94
|
</span>
|
|
107
|
-
<p className={cn('text-xs', isPositive ? colors.positive : colors.negative)}>
|
|
95
|
+
<p className={cn('text-xs sm:text-sm', isPositive ? colors.positive : colors.negative)}>
|
|
108
96
|
{trendValueFormatter(trend.value)}
|
|
109
97
|
</p>
|
|
110
98
|
{trend.label && (
|
|
111
|
-
<p className="text-xs text-muted-foreground ml-1">{trend.label}</p>
|
|
99
|
+
<p className="text-xs text-muted-foreground ml-1 hidden sm:inline">{trend.label}</p>
|
|
112
100
|
)}
|
|
113
101
|
</div>
|
|
114
102
|
)}
|
|
115
103
|
</div>
|
|
116
104
|
{Icon && (
|
|
117
|
-
<
|
|
105
|
+
<div className={cn(
|
|
106
|
+
responsiveLayout ? 'sm:self-auto' : ''
|
|
107
|
+
)}>
|
|
108
|
+
<Icon className={cn(getIconSize(size), iconColor, 'flex-shrink-0')} />
|
|
109
|
+
</div>
|
|
118
110
|
)}
|
|
119
111
|
</div>
|
|
120
112
|
</CardContent>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* QuickActionCard Component (Organism)
|
|
3
|
-
* @description Card component for quick navigation actions with icon
|
|
3
|
+
* @description Card component for quick navigation actions with icon (Responsive)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { forwardRef } from 'react';
|
|
7
|
-
import { cn } from '../../infrastructure/utils';
|
|
7
|
+
import { cn, getSpacing, getIconSize, getContainerSize, getTextSize, getGap } from '../../infrastructure/utils';
|
|
8
8
|
import type { BaseProps, SizeVariant } from '../../domain/types';
|
|
9
9
|
|
|
10
10
|
export interface QuickActionCardProps extends BaseProps {
|
|
@@ -16,32 +16,9 @@ export interface QuickActionCardProps extends BaseProps {
|
|
|
16
16
|
href?: string;
|
|
17
17
|
size?: Extract<SizeVariant, 'sm' | 'md' | 'lg'>;
|
|
18
18
|
target?: '_blank' | '_self' | '_parent' | '_top';
|
|
19
|
+
responsiveLayout?: boolean; // Enable responsive layout changes
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const sizeStyles = {
|
|
22
|
-
sm: 'p-3 gap-2',
|
|
23
|
-
md: 'p-4 gap-3',
|
|
24
|
-
lg: 'p-5 gap-4',
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const iconSizeStyles = {
|
|
28
|
-
sm: 'w-8 h-8',
|
|
29
|
-
md: 'w-10 h-10',
|
|
30
|
-
lg: 'w-12 h-12',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const iconInnerSizeStyles = {
|
|
34
|
-
sm: 'h-4 w-4',
|
|
35
|
-
md: 'h-5 w-5',
|
|
36
|
-
lg: 'h-6 w-6',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const labelSizeStyles = {
|
|
40
|
-
sm: 'text-xs',
|
|
41
|
-
md: 'text-sm',
|
|
42
|
-
lg: 'text-base',
|
|
43
|
-
};
|
|
44
|
-
|
|
45
22
|
export const QuickActionCard = forwardRef<HTMLAnchorElement | HTMLDivElement, QuickActionCardProps>(
|
|
46
23
|
(
|
|
47
24
|
{
|
|
@@ -54,6 +31,7 @@ export const QuickActionCard = forwardRef<HTMLAnchorElement | HTMLDivElement, Qu
|
|
|
54
31
|
href,
|
|
55
32
|
size = 'md',
|
|
56
33
|
target = '_self',
|
|
34
|
+
responsiveLayout = true,
|
|
57
35
|
...props
|
|
58
36
|
},
|
|
59
37
|
ref
|
|
@@ -61,11 +39,15 @@ export const QuickActionCard = forwardRef<HTMLAnchorElement | HTMLDivElement, Qu
|
|
|
61
39
|
const content = (
|
|
62
40
|
<>
|
|
63
41
|
{Icon && (
|
|
64
|
-
<div className={cn(
|
|
65
|
-
<Icon className={cn(
|
|
42
|
+
<div className={cn(getContainerSize(size), 'rounded-lg flex items-center justify-center flex-shrink-0', iconBgColor)}>
|
|
43
|
+
<Icon className={cn(getIconSize(size), iconColor)} />
|
|
66
44
|
</div>
|
|
67
45
|
)}
|
|
68
|
-
<span className={cn(
|
|
46
|
+
<span className={cn(
|
|
47
|
+
'font-medium text-foreground group-hover:text-primary transition-colors',
|
|
48
|
+
getTextSize(size),
|
|
49
|
+
responsiveLayout && 'text-center sm:text-left'
|
|
50
|
+
)}>
|
|
69
51
|
{label}
|
|
70
52
|
</span>
|
|
71
53
|
</>
|
|
@@ -73,9 +55,10 @@ export const QuickActionCard = forwardRef<HTMLAnchorElement | HTMLDivElement, Qu
|
|
|
73
55
|
|
|
74
56
|
const baseClasses = cn(
|
|
75
57
|
'bg-card border border-border rounded-xl',
|
|
76
|
-
'flex items-center',
|
|
77
|
-
'hover:border-primary/50 transition-
|
|
78
|
-
|
|
58
|
+
responsiveLayout ? 'flex flex-col sm:flex-row items-center justify-center sm:justify-start' : 'flex items-center',
|
|
59
|
+
'hover:border-primary/50 hover:shadow-md transition-all duration-200 group',
|
|
60
|
+
getSpacing('p', size),
|
|
61
|
+
getGap(size === 'sm' ? 'sm' : size),
|
|
79
62
|
className
|
|
80
63
|
);
|
|
81
64
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* StatCard Component (Organism)
|
|
3
|
-
* @description Enhanced metric card with progress bar and target tracking
|
|
3
|
+
* @description Enhanced metric card with progress bar and target tracking (Responsive)
|
|
4
4
|
* Combines functionality of KPICard and StatsCard from main app
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { forwardRef, type ComponentType } from 'react';
|
|
8
|
-
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import { cn, getSpacing, getIconSize, getContainerSize, getTextSize, getSpaceY, getGap } from '../../infrastructure/utils';
|
|
9
9
|
import { Card, CardContent } from './Card';
|
|
10
10
|
import { Progress } from '../atoms';
|
|
11
11
|
import { ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
|
@@ -28,39 +28,10 @@ export interface StatCardProps extends BaseProps {
|
|
|
28
28
|
variant?: 'default' | 'gradient' | 'elevated';
|
|
29
29
|
trendValueFormatter?: (value: number) => string;
|
|
30
30
|
onClick?: () => void;
|
|
31
|
-
changeLabel?: string;
|
|
31
|
+
changeLabel?: string;
|
|
32
|
+
responsiveLayout?: boolean;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
const sizeStyles = {
|
|
35
|
-
sm: 'p-3',
|
|
36
|
-
md: 'p-4',
|
|
37
|
-
lg: 'p-6',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const valueSizeStyles = {
|
|
41
|
-
sm: 'text-lg',
|
|
42
|
-
md: 'text-2xl',
|
|
43
|
-
lg: 'text-3xl',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const iconSizeStyles = {
|
|
47
|
-
sm: 'h-4 w-4',
|
|
48
|
-
md: 'h-8 w-8',
|
|
49
|
-
lg: 'h-12 w-12',
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const labelSizeStyles = {
|
|
53
|
-
sm: 'text-xs',
|
|
54
|
-
md: 'text-sm',
|
|
55
|
-
lg: 'text-base',
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const iconBgSizeStyles = {
|
|
59
|
-
sm: 'w-8 h-8',
|
|
60
|
-
md: 'w-12 h-12',
|
|
61
|
-
lg: 'w-16 h-16',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
35
|
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
|
|
65
36
|
(
|
|
66
37
|
{
|
|
@@ -76,14 +47,15 @@ export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
|
|
|
76
47
|
trendValueFormatter = (v) => `${v > 0 ? '+' : ''}${Math.abs(v).toFixed(1)}%`,
|
|
77
48
|
onClick,
|
|
78
49
|
changeLabel,
|
|
50
|
+
responsiveLayout = true,
|
|
79
51
|
...props
|
|
80
52
|
},
|
|
81
53
|
ref
|
|
82
54
|
) => {
|
|
83
55
|
const isPositive = trend?.value !== undefined && trend.value >= 0;
|
|
84
56
|
const GrowthIcon = isPositive ? ArrowUpRight : ArrowDownRight;
|
|
85
|
-
const trendColor = isPositive ? 'text-green-600' : 'text-red-600';
|
|
86
|
-
const iconBgColor = isPositive ? 'text-green-500' : 'text-red-500';
|
|
57
|
+
const trendColor = isPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
|
|
58
|
+
const iconBgColor = isPositive ? 'text-green-500 dark:text-green-400' : 'text-red-500 dark:text-red-400';
|
|
87
59
|
|
|
88
60
|
const cardVariant = variant === 'elevated' ? 'elevated' : 'default';
|
|
89
61
|
|
|
@@ -93,32 +65,37 @@ export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
|
|
|
93
65
|
variant={cardVariant}
|
|
94
66
|
className={cn(
|
|
95
67
|
'transition-all duration-300',
|
|
96
|
-
variant === 'gradient' && 'bg-gradient-to-br from-white to-gray-50 hover:shadow-lg border-0 shadow-md',
|
|
68
|
+
variant === 'gradient' && 'bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 hover:shadow-lg border-0 shadow-md',
|
|
97
69
|
onClick && 'cursor-pointer hover:shadow-lg hover:border-primary/50',
|
|
98
70
|
className
|
|
99
71
|
)}
|
|
100
72
|
onClick={onClick}
|
|
101
73
|
{...props}
|
|
102
74
|
>
|
|
103
|
-
<CardContent className={
|
|
104
|
-
<div className=
|
|
75
|
+
<CardContent className={getSpacing('p', size)}>
|
|
76
|
+
<div className={cn(
|
|
77
|
+
'flex items-center justify-between mb-4',
|
|
78
|
+
getGap('md'),
|
|
79
|
+
'flex-col sm:flex-row sm:mb-6'
|
|
80
|
+
)}>
|
|
105
81
|
{Icon && (
|
|
106
82
|
<div
|
|
107
83
|
className={cn(
|
|
108
84
|
'rounded-xl flex items-center justify-center',
|
|
109
85
|
variant === 'gradient' && 'bg-gradient-to-br from-teal-500 to-cyan-500',
|
|
110
86
|
variant !== 'gradient' && `bg-${iconColor.split('-')[1]}-500/10`,
|
|
111
|
-
|
|
87
|
+
getContainerSize(size),
|
|
88
|
+
responsiveLayout && 'w-full sm:w-auto'
|
|
112
89
|
)}
|
|
113
90
|
>
|
|
114
|
-
<Icon className={cn(
|
|
91
|
+
<Icon className={cn(getIconSize(size), variant === 'gradient' ? 'text-white' : iconColor)} />
|
|
115
92
|
</div>
|
|
116
93
|
)}
|
|
117
94
|
{trend && (
|
|
118
95
|
<span
|
|
119
96
|
className={cn(
|
|
120
|
-
'text-sm font-medium px-2 py-1 rounded-full',
|
|
121
|
-
isPositive ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100'
|
|
97
|
+
'text-xs sm:text-sm font-medium px-2 py-1 rounded-full whitespace-nowrap',
|
|
98
|
+
isPositive ? 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/30' : 'text-red-700 bg-red-100 dark:text-red-300 dark:bg-red-900/30'
|
|
122
99
|
)}
|
|
123
100
|
>
|
|
124
101
|
{changeLabel || trendValueFormatter(trend.value)}
|
|
@@ -126,29 +103,34 @@ export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
|
|
|
126
103
|
)}
|
|
127
104
|
</div>
|
|
128
105
|
|
|
129
|
-
<div className=
|
|
130
|
-
<div
|
|
131
|
-
|
|
106
|
+
<div className={getSpaceY('md')}>
|
|
107
|
+
<div className={cn(
|
|
108
|
+
responsiveLayout && 'text-center sm:text-left'
|
|
109
|
+
)}>
|
|
110
|
+
<p className={cn('font-bold text-gray-900 dark:text-gray-100', size === 'sm' ? 'text-lg sm:text-xl' : size === 'md' ? 'text-2xl sm:text-3xl' : 'text-3xl sm:text-4xl')}>
|
|
132
111
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
|
133
112
|
</p>
|
|
134
|
-
<p className={cn('text-muted-foreground',
|
|
113
|
+
<p className={cn('text-muted-foreground', getTextSize(size))}>{title}</p>
|
|
135
114
|
</div>
|
|
136
115
|
|
|
137
116
|
{target && (
|
|
138
|
-
<div className=
|
|
139
|
-
<div className="flex justify-between text-xs">
|
|
117
|
+
<div className={getSpaceY('sm')}>
|
|
118
|
+
<div className="flex justify-between text-xs sm:text-sm">
|
|
140
119
|
<span className="text-muted-foreground">Target: {target.value}</span>
|
|
141
|
-
<span className="text-teal-600">{target.progress}%</span>
|
|
120
|
+
<span className="text-teal-600 dark:text-teal-400 font-medium">{target.progress}%</span>
|
|
142
121
|
</div>
|
|
143
|
-
<Progress value={target.progress} className="h-2" />
|
|
122
|
+
<Progress value={target.progress} className="h-2 sm:h-2.5" />
|
|
144
123
|
</div>
|
|
145
124
|
)}
|
|
146
125
|
|
|
147
126
|
{trend && variant !== 'gradient' && (
|
|
148
|
-
<div className=
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
127
|
+
<div className={cn(
|
|
128
|
+
'flex items-center mt-1',
|
|
129
|
+
responsiveLayout && 'justify-center sm:justify-start'
|
|
130
|
+
)}>
|
|
131
|
+
<GrowthIcon className={cn('h-3 w-3 sm:h-4 sm:w-4 mr-1', iconBgColor)} />
|
|
132
|
+
<p className={cn('text-xs sm:text-sm', trendColor)}>{trendValueFormatter(trend.value)}</p>
|
|
133
|
+
{trend.label && <p className="text-xs text-muted-foreground ml-1 hidden xs:inline">{trend.label}</p>}
|
|
152
134
|
</div>
|
|
153
135
|
)}
|
|
154
136
|
</div>
|
|
@@ -92,6 +92,9 @@ export type { StatCardProps } from './StatCard';
|
|
|
92
92
|
export { FormModal } from './FormModal';
|
|
93
93
|
export type { FormModalProps } from './FormModal';
|
|
94
94
|
|
|
95
|
+
export { Grid, GridItem } from './Grid';
|
|
96
|
+
export type { GridProps, GridItemProps } from './Grid';
|
|
97
|
+
|
|
95
98
|
export { ConfirmDialog } from './ConfirmDialog';
|
|
96
99
|
export type { ConfirmDialogProps } from './ConfirmDialog';
|
|
97
100
|
|