@ews-admin/global-design-system 1.1.20 → 1.1.22

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.
@@ -49,4 +49,23 @@ export declare const formatNumeric: (value: string) => string;
49
49
  * @returns Boolean indicating if the phone number is valid
50
50
  */
51
51
  export declare function isValidPhoneNumber(value: string): boolean;
52
+ /**
53
+ * Blood type enum matching backend BloodTypeEnum values
54
+ */
55
+ export declare enum BloodType {
56
+ A_POSITIVE = "A+",
57
+ A_NEGATIVE = "A-",
58
+ B_POSITIVE = "B+",
59
+ B_NEGATIVE = "B-",
60
+ AB_POSITIVE = "AB+",
61
+ AB_NEGATIVE = "AB-",
62
+ O_POSITIVE = "O+",
63
+ O_NEGATIVE = "O-",
64
+ UNKNOWN = "UNKNOWN"
65
+ }
66
+ /**
67
+ * Ordered list of all blood type values.
68
+ * Use this to build select/dropdown options.
69
+ */
70
+ export declare const BLOOD_TYPES: BloodType[];
52
71
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAE7C;;GAEG;AACH,eAAO,MAAM,QAAQ,QAAQ,CAAC;AAE9B;;;;GAIG;AACH,wBAAgB,EAAE,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,UAEzC;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,SAAW,GAAG,MAAM,CAK1E;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,EAC5B,OAAO,CAAC,EAAE,IAAI,CAAC,qBAAqB,GACnC,MAAM,CAQR;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,EAChE,IAAI,EAAE,CAAC,EACP,IAAI,EAAE,MAAM,GACX,CAAC,GAAG,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,CAMlC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,SAAQ,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAE7C,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMzD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAE7C;;GAEG;AACH,eAAO,MAAM,QAAQ,QAAQ,CAAC;AAE9B;;;;GAIG;AACH,wBAAgB,EAAE,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,UAEzC;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,SAAW,GAAG,MAAM,CAK1E;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,EAC5B,OAAO,CAAC,EAAE,IAAI,CAAC,qBAAqB,GACnC,MAAM,CAQR;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,EAChE,IAAI,EAAE,CAAC,EACP,IAAI,EAAE,MAAM,GACX,CAAC,GAAG,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,CAMlC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,SAAQ,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAE7C,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMzD;AAED;;GAEG;AACH,oBAAY,SAAS;IACnB,UAAU,OAAO;IACjB,UAAU,OAAO;IACjB,UAAU,OAAO;IACjB,UAAU,OAAO;IACjB,WAAW,QAAQ;IACnB,WAAW,QAAQ;IACnB,UAAU,OAAO;IACjB,UAAU,OAAO;IACjB,OAAO,YAAY;CACpB;AAED;;;GAGG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,EAUlC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "EWS Global Design System - Reusable components for EWS applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
@@ -53,6 +53,14 @@ export interface InputProps
53
53
  * Icon to display after the input
54
54
  */
55
55
  rightIcon?: React.ReactNode;
56
+ /**
57
+ * Text or node to display as a left addon (e.g., currency "FCFA")
58
+ */
59
+ leftAddon?: React.ReactNode;
60
+ /**
61
+ * Text or node to display as a right addon inside the input (e.g., currency "FCFA")
62
+ */
63
+ rightAddon?: React.ReactNode;
56
64
  /**
57
65
  * Whether the input should take full width
58
66
  */
@@ -86,6 +94,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
86
94
  showPasswordToggle = false,
87
95
  required = false,
88
96
  countryCodeSelect,
97
+ leftAddon,
98
+ rightAddon,
89
99
  id,
90
100
  type = "text",
91
101
  ...props
@@ -274,13 +284,20 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
274
284
  </div>
275
285
  </div>
276
286
  )}
277
- {leftIcon && !countryCodeSelect && (
287
+ {leftIcon && !countryCodeSelect && !leftAddon && (
278
288
  <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
279
289
  <span className={cn("text-ews-gray-400", iconSizes[size])}>
280
290
  {leftIcon}
281
291
  </span>
282
292
  </div>
283
293
  )}
294
+ {leftAddon && !countryCodeSelect && !leftIcon && (
295
+ <div className="flex absolute inset-y-0 left-0 items-center pointer-events-none overflow-hidden rounded-l-md">
296
+ <span className="flex items-center h-full px-3 text-sm font-medium whitespace-nowrap bg-ews-gray-50 border-r border-ews-gray-300 text-ews-gray-600">
297
+ {leftAddon}
298
+ </span>
299
+ </div>
300
+ )}
284
301
  <input
285
302
  id={inputId}
286
303
  type={actualType}
@@ -289,13 +306,22 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
289
306
  variants[actualVariant],
290
307
  sizes[size],
291
308
  countryCodeSelect && "pl-24",
292
- leftIcon && !countryCodeSelect && "pl-10",
309
+ leftIcon && !countryCodeSelect && !leftAddon && "pl-10",
310
+ leftAddon && !countryCodeSelect && !leftIcon && "pl-16",
293
311
  (rightIcon || shouldShowPasswordToggle) && "pr-10",
312
+ rightAddon && !rightIcon && !shouldShowPasswordToggle && "pr-16",
294
313
  className
295
314
  )}
296
315
  ref={ref}
297
316
  {...props}
298
317
  />
318
+ {rightAddon && !rightIcon && !shouldShowPasswordToggle && (
319
+ <div className="flex absolute inset-y-0 right-0 items-center pr-3 pointer-events-none">
320
+ <span className="text-sm font-medium text-ews-gray-500">
321
+ {rightAddon}
322
+ </span>
323
+ </div>
324
+ )}
299
325
  {rightIcon && !shouldShowPasswordToggle && (
300
326
  <div className="flex absolute inset-y-0 right-0 items-center pr-3 pointer-events-none">
301
327
  <span className={cn("text-ews-gray-400", iconSizes[size])}>
@@ -36,6 +36,10 @@ export interface ModalProps {
36
36
  * Modal variant
37
37
  */
38
38
  variant?: "error" | "warning" | "confirmation" | "info";
39
+ /**
40
+ * Modal size/width
41
+ */
42
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
39
43
  /**
40
44
  * Primary action button text
41
45
  */
@@ -80,6 +84,7 @@ const Modal = ({
80
84
  title,
81
85
  children,
82
86
  variant = "info",
87
+ size = "md",
83
88
  primaryAction,
84
89
  secondaryAction,
85
90
  onPrimaryAction,
@@ -147,6 +152,18 @@ const Modal = ({
147
152
 
148
153
  const variantStyles = getVariantStyles();
149
154
 
155
+ const getSizeClasses = () => {
156
+ const sizeMap = {
157
+ sm: "max-w-sm",
158
+ md: "max-w-md",
159
+ lg: "max-w-lg",
160
+ xl: "max-w-xl",
161
+ "2xl": "max-w-2xl",
162
+ full: "max-w-full",
163
+ };
164
+ return sizeMap[size] || sizeMap.md;
165
+ };
166
+
150
167
  const handleOverlayClick = (e: React.MouseEvent) => {
151
168
  if (e.target === e.currentTarget && closeOnOverlayClick) {
152
169
  onClose();
@@ -164,8 +181,10 @@ const Modal = ({
164
181
  {/* Modal */}
165
182
  <div
166
183
  className={cn(
167
- "relative mx-4 w-full max-w-md bg-white rounded-lg shadow-xl transition-all transform",
184
+ "relative w-full bg-white rounded-lg shadow-xl transition-all transform",
168
185
  "duration-200 animate-in fade-in-0 zoom-in-95",
186
+ getSizeClasses(),
187
+ "mx-4",
169
188
  className
170
189
  )}
171
190
  role="dialog"
@@ -0,0 +1,231 @@
1
+ import { Pencil, Trash } from "lucide-react";
2
+ import React, { useEffect, useRef, useState } from "react";
3
+
4
+ import { cn } from "../../utils";
5
+ import { Modal } from "../Modal/Modal";
6
+
7
+ export interface ProfileImageUploadProps {
8
+ /** Current image URL */
9
+ imageUrl: string;
10
+ /** Alt text for accessibility */
11
+ altText: string;
12
+ /** Disable editing */
13
+ readOnly?: boolean;
14
+ /** Avatar diameter */
15
+ size?: "sm" | "md" | "lg";
16
+ /**
17
+ * Upload progress 0–100.
18
+ * Progress bar is hidden when 0; auto-hides 1 s after reaching 100.
19
+ */
20
+ uploadProgress?: number;
21
+ /**
22
+ * When true the progress bar stays visible in an indeterminate (pulsing)
23
+ * state for phases with no measurable progress (pre-upload / post-upload).
24
+ * The bar auto-hides 1 s after both `isLoading` is false and progress reached 100.
25
+ */
26
+ isLoading?: boolean;
27
+ /**
28
+ * Whether to show the delete button.
29
+ * Pass `false` when `imageUrl` is a default/fallback image.
30
+ */
31
+ showDeleteButton?: boolean;
32
+ /** File input accept attribute (default: "image/*") */
33
+ accept?: string;
34
+ /** Max allowed file size in MB (default: 3) */
35
+ maxFileSizeMB?: number;
36
+ /** Called when a valid file is selected */
37
+ onFileSelect: (file: File) => void;
38
+ /** Called when the selected file exceeds `maxFileSizeMB` */
39
+ onFileSizeExceeded?: () => void;
40
+ /** Called when the user confirms deletion in the modal */
41
+ onDeleteConfirm: () => void;
42
+ /** Deletion confirmation modal — title text */
43
+ deleteConfirmTitle: string;
44
+ /** Deletion confirmation modal — body text */
45
+ deleteConfirmMessage: string;
46
+ /** Deletion confirmation modal — primary button label */
47
+ deleteConfirmLabel: string;
48
+ /** Deletion confirmation modal — secondary / cancel label */
49
+ cancelLabel: string;
50
+ }
51
+
52
+ const SIZE_CLASSES: Record<"sm" | "md" | "lg", string> = {
53
+ sm: "h-10 w-10",
54
+ md: "h-16 w-16",
55
+ lg: "h-32 w-32",
56
+ };
57
+
58
+ const INDICATOR_CLASSES: Record<"sm" | "md" | "lg", string> = {
59
+ sm: "h-2 w-2",
60
+ md: "h-4 w-4",
61
+ lg: "h-8 w-8",
62
+ };
63
+
64
+ function UploadProgressBar({
65
+ progress,
66
+ isLoading,
67
+ }: {
68
+ progress: number;
69
+ isLoading?: boolean;
70
+ }) {
71
+ const [visible, setVisible] = useState(false);
72
+
73
+ useEffect(() => {
74
+ if (isLoading) {
75
+ setVisible(true);
76
+ return;
77
+ }
78
+ // isLoading just turned false
79
+ if (progress >= 100) {
80
+ setVisible(true);
81
+ const t = setTimeout(() => setVisible(false), 1000);
82
+ return () => clearTimeout(t);
83
+ }
84
+ // Legacy path (no isLoading prop): driven purely by progress
85
+ if (progress > 0 && progress < 100) {
86
+ setVisible(true);
87
+ return;
88
+ }
89
+ setVisible(false);
90
+ }, [progress, isLoading]);
91
+
92
+ if (!visible) return null;
93
+
94
+ const indeterminate = isLoading && progress === 0;
95
+
96
+ return (
97
+ <div className="mt-2 h-1.5 w-32 rounded-full bg-gray-200">
98
+ <div
99
+ className={cn(
100
+ "h-full rounded-full bg-ews-primary transition-all duration-300",
101
+ indeterminate && "animate-pulse"
102
+ )}
103
+ style={{ width: indeterminate ? "35%" : `${progress}%` }}
104
+ />
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
110
+ imageUrl,
111
+ altText,
112
+ readOnly = false,
113
+ size = "lg",
114
+ uploadProgress = 0,
115
+ isLoading = false,
116
+ showDeleteButton = true,
117
+ accept = "image/*",
118
+ maxFileSizeMB = 3,
119
+ onFileSelect,
120
+ onFileSizeExceeded,
121
+ onDeleteConfirm,
122
+ deleteConfirmTitle,
123
+ deleteConfirmMessage,
124
+ deleteConfirmLabel,
125
+ cancelLabel,
126
+ }) => {
127
+ const fileInputRef = useRef<HTMLInputElement>(null);
128
+ const [isHovered, setIsHovered] = useState(false);
129
+ const [showConfirm, setShowConfirm] = useState(false);
130
+
131
+ const handleEditClick = () => fileInputRef.current?.click();
132
+
133
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
134
+ const file = e.target.files?.[0];
135
+ if (!file) return;
136
+
137
+ if (file.size > maxFileSizeMB * 1024 * 1024) {
138
+ onFileSizeExceeded?.();
139
+ e.target.value = "";
140
+ return;
141
+ }
142
+
143
+ onFileSelect(file);
144
+ e.target.value = ""; // allow re-selecting the same file
145
+ };
146
+
147
+ const handleConfirmDelete = () => {
148
+ setShowConfirm(false);
149
+ onDeleteConfirm();
150
+ };
151
+
152
+ return (
153
+ <>
154
+ <div className="inline-flex flex-col items-center">
155
+ <div
156
+ className="relative"
157
+ onMouseEnter={() => setIsHovered(true)}
158
+ onMouseLeave={() => setIsHovered(false)}
159
+ >
160
+ <img
161
+ src={imageUrl}
162
+ alt={altText}
163
+ className={cn(
164
+ "rounded-full border-4 border-white object-cover",
165
+ SIZE_CLASSES[size]
166
+ )}
167
+ />
168
+
169
+ {/* Online status dot */}
170
+ <div
171
+ className={cn(
172
+ "absolute bottom-0 right-0 rounded-full border-4 border-white bg-green-400",
173
+ INDICATOR_CLASSES[size]
174
+ )}
175
+ />
176
+
177
+ <input
178
+ type="file"
179
+ ref={fileInputRef}
180
+ accept={accept}
181
+ className="hidden"
182
+ onChange={handleFileChange}
183
+ />
184
+
185
+ {isHovered && !readOnly && !isLoading && (
186
+ <>
187
+ {/* Edit / upload button */}
188
+ <button
189
+ type="button"
190
+ onClick={handleEditClick}
191
+ className="absolute left-0 top-0 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-ews-primary transition-colors hover:bg-ews-secondary"
192
+ aria-label="Edit profile image"
193
+ >
194
+ <Pencil className="h-4 w-4 text-white" />
195
+ </button>
196
+
197
+ {/* Delete button — only when not a fallback/default image */}
198
+ {showDeleteButton && (
199
+ <button
200
+ type="button"
201
+ onClick={() => setShowConfirm(true)}
202
+ className="absolute left-0 top-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-red-600 transition-colors hover:bg-red-700"
203
+ aria-label="Delete profile image"
204
+ >
205
+ <Trash className="h-4 w-4 text-white" />
206
+ </button>
207
+ )}
208
+ </>
209
+ )}
210
+ </div>
211
+
212
+ <UploadProgressBar progress={uploadProgress} isLoading={isLoading} />
213
+ </div>
214
+
215
+ {/* Delete confirmation */}
216
+ <Modal
217
+ isOpen={showConfirm}
218
+ onClose={() => setShowConfirm(false)}
219
+ title={deleteConfirmTitle}
220
+ variant="error"
221
+ size="sm"
222
+ primaryAction={deleteConfirmLabel}
223
+ secondaryAction={cancelLabel}
224
+ onPrimaryAction={handleConfirmDelete}
225
+ onSecondaryAction={() => setShowConfirm(false)}
226
+ >
227
+ <p>{deleteConfirmMessage}</p>
228
+ </Modal>
229
+ </>
230
+ );
231
+ };
@@ -0,0 +1,2 @@
1
+ export { ProfileImageUpload } from "./ProfileImageUpload";
2
+ export type { ProfileImageUploadProps } from "./ProfileImageUpload";
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export { MultiSearchAutocomplete } from "./components/MultiSearchAutocomplete";
16
16
  export { Modal } from "./components/Modal";
17
17
  export type { ErrorField, ErrorObject, ModalProps } from "./components/Modal";
18
18
 
19
+ export { ProfileImageUpload } from "./components/ProfileImageUpload";
20
+ export type { ProfileImageUploadProps } from "./components/ProfileImageUpload";
21
+
19
22
  export { DropdownMultiSelect } from "./components/DropdownMultiSelect";
20
23
  export type { DropdownMultiSelectProps } from "./components/DropdownMultiSelect";
21
24
 
@@ -46,6 +49,8 @@ export type { IconProps, SimpleIconProps } from "./icons";
46
49
 
47
50
  // Utils
48
51
  export {
52
+ BLOOD_TYPES,
53
+ BloodType,
49
54
  cn,
50
55
  debounce,
51
56
  formatCurrency,
@@ -94,3 +94,34 @@ export function isValidPhoneNumber(value: string): boolean {
94
94
  const phoneRegex = /^(\+\d{1,17}|\d{1,17})$/;
95
95
  return phoneRegex.test(trimmedValue);
96
96
  }
97
+
98
+ /**
99
+ * Blood type enum matching backend BloodTypeEnum values
100
+ */
101
+ export enum BloodType {
102
+ A_POSITIVE = "A+",
103
+ A_NEGATIVE = "A-",
104
+ B_POSITIVE = "B+",
105
+ B_NEGATIVE = "B-",
106
+ AB_POSITIVE = "AB+",
107
+ AB_NEGATIVE = "AB-",
108
+ O_POSITIVE = "O+",
109
+ O_NEGATIVE = "O-",
110
+ UNKNOWN = "UNKNOWN",
111
+ }
112
+
113
+ /**
114
+ * Ordered list of all blood type values.
115
+ * Use this to build select/dropdown options.
116
+ */
117
+ export const BLOOD_TYPES: BloodType[] = [
118
+ BloodType.A_POSITIVE,
119
+ BloodType.A_NEGATIVE,
120
+ BloodType.B_POSITIVE,
121
+ BloodType.B_NEGATIVE,
122
+ BloodType.AB_POSITIVE,
123
+ BloodType.AB_NEGATIVE,
124
+ BloodType.O_POSITIVE,
125
+ BloodType.O_NEGATIVE,
126
+ BloodType.UNKNOWN,
127
+ ];