@ews-admin/global-design-system 1.14.0 → 1.18.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/dist/components/DropdownMultiSelect/DropdownMultiSelect.d.ts +1 -1
- package/dist/components/DropdownMultiSelect/DropdownMultiSelect.d.ts.map +1 -1
- package/dist/components/ImageCropModal/ImageCropModal.d.ts +28 -0
- package/dist/components/ImageCropModal/ImageCropModal.d.ts.map +1 -0
- package/dist/components/ImageCropModal/cropImage.d.ts +3 -0
- package/dist/components/ImageCropModal/cropImage.d.ts.map +1 -0
- package/dist/components/ImageCropModal/index.d.ts +3 -0
- package/dist/components/ImageCropModal/index.d.ts.map +1 -0
- package/dist/components/Logo/Logo.d.ts +1 -1
- package/dist/components/Logo/Logo.d.ts.map +1 -1
- package/dist/components/Modal/Modal.d.ts +1 -1
- package/dist/components/Modal/Modal.d.ts.map +1 -1
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts +1 -1
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts.map +1 -1
- package/dist/components/ProfileImageUpload/ProfileImageUpload.d.ts +18 -5
- package/dist/components/ProfileImageUpload/ProfileImageUpload.d.ts.map +1 -1
- package/dist/components/ProfileImageUpload/index.d.ts +1 -1
- package/dist/components/ProfileImageUpload/index.d.ts.map +1 -1
- package/dist/components/RegionSelect/RegionSelect.d.ts +2 -1
- package/dist/components/RegionSelect/RegionSelect.d.ts.map +1 -1
- package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts +2 -1
- package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +7 -0
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Tag/Tag.d.ts +8 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/index.d.ts +3 -0
- package/dist/components/Tag/index.d.ts.map +1 -0
- package/dist/components/TagList/TagList.d.ts +14 -0
- package/dist/components/TagList/TagList.d.ts.map +1 -0
- package/dist/components/TagList/index.d.ts +3 -0
- package/dist/components/TagList/index.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +108 -43
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +1429 -35
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1449 -35
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/ImageCropModal/ImageCropModal.tsx +110 -0
- package/src/components/ImageCropModal/cropImage.ts +48 -0
- package/src/components/ImageCropModal/index.ts +2 -0
- package/src/components/ProfileImageUpload/ProfileImageUpload.tsx +36 -9
- package/src/components/ProfileImageUpload/index.ts +4 -1
- package/src/components/Select/Select.tsx +48 -50
- package/src/components/Tag/Tag.tsx +33 -0
- package/src/components/Tag/index.ts +2 -0
- package/src/components/TagList/TagList.tsx +60 -0
- package/src/components/TagList/index.ts +2 -0
- package/src/index.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ews-admin/global-design-system",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
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",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"clsx": "^2.0.0",
|
|
65
|
-
"lucide-react": "^0.544.0"
|
|
65
|
+
"lucide-react": "^0.544.0",
|
|
66
|
+
"react-easy-crop": "^6.0.2"
|
|
66
67
|
},
|
|
67
68
|
"scripts": {
|
|
68
69
|
"build": "rollup -c",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useCallback, useState } from "react";
|
|
2
|
+
// react-easy-crop's .d.ts uses `export type *` which Node moduleResolution
|
|
3
|
+
// can't resolve as a value. Cast to any here — we own all props passed below.
|
|
4
|
+
import EasyCrop from "react-easy-crop";
|
|
5
|
+
import type { Area, Point } from "react-easy-crop";
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
const Cropper = EasyCrop as unknown as React.ComponentType<any>;
|
|
9
|
+
|
|
10
|
+
import { Modal } from "../Modal/Modal";
|
|
11
|
+
import { getCroppedFile } from "./cropImage";
|
|
12
|
+
|
|
13
|
+
export interface ImageCropModalProps {
|
|
14
|
+
/** Whether the modal is open */
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
/** Object URL of the image to crop (created with URL.createObjectURL) */
|
|
17
|
+
imageSrc: string;
|
|
18
|
+
/** Original file name — used to derive the extension of the cropped file */
|
|
19
|
+
originalFileName: string;
|
|
20
|
+
/**
|
|
21
|
+
* Locked aspect ratio for the crop area.
|
|
22
|
+
* Pass 1 for 1:1 (profile photos), 3 for 3:1 (banners), 4/3, etc.
|
|
23
|
+
* Defaults to 1 (square).
|
|
24
|
+
*/
|
|
25
|
+
aspectRatio?: number;
|
|
26
|
+
/** Called when the user cancels or closes the modal */
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Called with the cropped File after the user confirms.
|
|
30
|
+
* The File is a JPEG produced from the selected crop region.
|
|
31
|
+
*/
|
|
32
|
+
onCropComplete: (croppedFile: File) => void;
|
|
33
|
+
// ── Label props (pass translated strings from the consuming app) ──
|
|
34
|
+
title?: string;
|
|
35
|
+
confirmLabel?: string;
|
|
36
|
+
cancelLabel?: string;
|
|
37
|
+
zoomLabel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ImageCropModal: React.FC<ImageCropModalProps> = ({
|
|
41
|
+
isOpen,
|
|
42
|
+
imageSrc,
|
|
43
|
+
originalFileName,
|
|
44
|
+
aspectRatio = 1,
|
|
45
|
+
onClose,
|
|
46
|
+
onCropComplete,
|
|
47
|
+
title = "Crop image",
|
|
48
|
+
confirmLabel = "Crop & Upload",
|
|
49
|
+
cancelLabel = "Cancel",
|
|
50
|
+
zoomLabel = "Zoom",
|
|
51
|
+
}) => {
|
|
52
|
+
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
|
|
53
|
+
const [zoom, setZoom] = useState(1);
|
|
54
|
+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
|
55
|
+
const [isCropping, setIsCropping] = useState(false);
|
|
56
|
+
|
|
57
|
+
const handleCropComplete = useCallback((_: Area, pixels: Area) => {
|
|
58
|
+
setCroppedAreaPixels(pixels);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const handleConfirm = async () => {
|
|
62
|
+
if (!croppedAreaPixels) return;
|
|
63
|
+
setIsCropping(true);
|
|
64
|
+
try {
|
|
65
|
+
const file = await getCroppedFile(imageSrc, croppedAreaPixels, originalFileName);
|
|
66
|
+
onCropComplete(file);
|
|
67
|
+
} finally {
|
|
68
|
+
setIsCropping(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Modal
|
|
74
|
+
isOpen={isOpen}
|
|
75
|
+
onClose={onClose}
|
|
76
|
+
title={title}
|
|
77
|
+
size="lg"
|
|
78
|
+
primaryAction={confirmLabel}
|
|
79
|
+
secondaryAction={cancelLabel}
|
|
80
|
+
onPrimaryAction={handleConfirm}
|
|
81
|
+
onSecondaryAction={onClose}
|
|
82
|
+
isLoading={isCropping}
|
|
83
|
+
closeOnOverlayClick={false}
|
|
84
|
+
>
|
|
85
|
+
<div className="relative h-80 w-full bg-gray-900">
|
|
86
|
+
<Cropper
|
|
87
|
+
image={imageSrc}
|
|
88
|
+
crop={crop}
|
|
89
|
+
zoom={zoom}
|
|
90
|
+
aspect={aspectRatio}
|
|
91
|
+
onCropChange={setCrop}
|
|
92
|
+
onZoomChange={setZoom}
|
|
93
|
+
onCropComplete={handleCropComplete}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="mt-4 px-1">
|
|
97
|
+
<label className="mb-1 block text-sm text-gray-500">{zoomLabel}</label>
|
|
98
|
+
<input
|
|
99
|
+
type="range"
|
|
100
|
+
min={1}
|
|
101
|
+
max={3}
|
|
102
|
+
step={0.05}
|
|
103
|
+
value={zoom}
|
|
104
|
+
onChange={(e) => setZoom(Number(e.target.value))}
|
|
105
|
+
className="w-full accent-ews-primary"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</Modal>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Area } from "react-easy-crop";
|
|
2
|
+
|
|
3
|
+
export async function getCroppedFile(
|
|
4
|
+
imageSrc: string,
|
|
5
|
+
pixelCrop: Area,
|
|
6
|
+
originalFileName: string,
|
|
7
|
+
): Promise<File> {
|
|
8
|
+
const image = await loadImage(imageSrc);
|
|
9
|
+
const canvas = document.createElement("canvas");
|
|
10
|
+
canvas.width = pixelCrop.width;
|
|
11
|
+
canvas.height = pixelCrop.height;
|
|
12
|
+
|
|
13
|
+
const ctx = canvas.getContext("2d");
|
|
14
|
+
if (!ctx) throw new Error("Could not get 2d canvas context");
|
|
15
|
+
|
|
16
|
+
ctx.drawImage(
|
|
17
|
+
image,
|
|
18
|
+
pixelCrop.x,
|
|
19
|
+
pixelCrop.y,
|
|
20
|
+
pixelCrop.width,
|
|
21
|
+
pixelCrop.height,
|
|
22
|
+
0,
|
|
23
|
+
0,
|
|
24
|
+
pixelCrop.width,
|
|
25
|
+
pixelCrop.height,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
canvas.toBlob((blob) => {
|
|
30
|
+
if (!blob) {
|
|
31
|
+
reject(new Error("canvas.toBlob produced no output"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const ext =
|
|
35
|
+
originalFileName.substring(originalFileName.lastIndexOf(".")) || ".jpg";
|
|
36
|
+
resolve(new File([blob], `cropped${ext}`, { type: "image/jpeg" }));
|
|
37
|
+
}, "image/jpeg");
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const img = new Image();
|
|
44
|
+
img.addEventListener("load", () => resolve(img));
|
|
45
|
+
img.addEventListener("error", reject);
|
|
46
|
+
img.src = src;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -4,8 +4,11 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import { cn } from "../../utils";
|
|
5
5
|
import { Modal } from "../Modal/Modal";
|
|
6
6
|
|
|
7
|
+
const ACCEPTED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
|
|
8
|
+
export type AcceptedImageMimeType = (typeof ACCEPTED_MIME_TYPES)[number];
|
|
9
|
+
|
|
7
10
|
export interface ProfileImageUploadProps {
|
|
8
|
-
/** Current image URL */
|
|
11
|
+
/** Current image URL — pass your default placeholder URL when no custom image is set */
|
|
9
12
|
imageUrl: string;
|
|
10
13
|
/** Alt text for accessibility */
|
|
11
14
|
altText: string;
|
|
@@ -29,14 +32,24 @@ export interface ProfileImageUploadProps {
|
|
|
29
32
|
* Pass `false` when `imageUrl` is a default/fallback image.
|
|
30
33
|
*/
|
|
31
34
|
showDeleteButton?: boolean;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Accepted MIME types (default: JPEG, PNG, WEBP).
|
|
37
|
+
* Used for both the file-input `accept` attribute and client-side validation.
|
|
38
|
+
*/
|
|
39
|
+
acceptedFormats?: AcceptedImageMimeType[];
|
|
40
|
+
/** Max allowed file size in MB (default: 5) */
|
|
35
41
|
maxFileSizeMB?: number;
|
|
36
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Optional aspect-ratio hint shown below the avatar (e.g. "1:1", "4:3").
|
|
44
|
+
* Pure informational — no cropping is enforced client-side.
|
|
45
|
+
*/
|
|
46
|
+
aspectRatioHint?: string;
|
|
47
|
+
/** Called with the validated File once the user picks an acceptable file */
|
|
37
48
|
onFileSelect: (file: File) => void;
|
|
38
49
|
/** Called when the selected file exceeds `maxFileSizeMB` */
|
|
39
50
|
onFileSizeExceeded?: () => void;
|
|
51
|
+
/** Called when the selected file's MIME type is not in `acceptedFormats` */
|
|
52
|
+
onInvalidFormat?: () => void;
|
|
40
53
|
/** Called when the user confirms deletion in the modal */
|
|
41
54
|
onDeleteConfirm: () => void;
|
|
42
55
|
/** Deletion confirmation modal — title text */
|
|
@@ -114,10 +127,12 @@ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
|
|
|
114
127
|
uploadProgress = 0,
|
|
115
128
|
isLoading = false,
|
|
116
129
|
showDeleteButton = true,
|
|
117
|
-
|
|
118
|
-
maxFileSizeMB =
|
|
130
|
+
acceptedFormats = [...ACCEPTED_MIME_TYPES],
|
|
131
|
+
maxFileSizeMB = 5,
|
|
132
|
+
aspectRatioHint,
|
|
119
133
|
onFileSelect,
|
|
120
134
|
onFileSizeExceeded,
|
|
135
|
+
onInvalidFormat,
|
|
121
136
|
onDeleteConfirm,
|
|
122
137
|
deleteConfirmTitle,
|
|
123
138
|
deleteConfirmMessage,
|
|
@@ -128,12 +143,20 @@ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
|
|
|
128
143
|
const [isHovered, setIsHovered] = useState(false);
|
|
129
144
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
130
145
|
|
|
146
|
+
const acceptAttr = acceptedFormats.join(",");
|
|
147
|
+
|
|
131
148
|
const handleEditClick = () => fileInputRef.current?.click();
|
|
132
149
|
|
|
133
150
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
134
151
|
const file = e.target.files?.[0];
|
|
135
152
|
if (!file) return;
|
|
136
153
|
|
|
154
|
+
if (acceptedFormats.indexOf(file.type as AcceptedImageMimeType) === -1) {
|
|
155
|
+
onInvalidFormat?.();
|
|
156
|
+
e.target.value = "";
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
if (file.size > maxFileSizeMB * 1024 * 1024) {
|
|
138
161
|
onFileSizeExceeded?.();
|
|
139
162
|
e.target.value = "";
|
|
@@ -141,7 +164,7 @@ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
|
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
onFileSelect(file);
|
|
144
|
-
e.target.value = "";
|
|
167
|
+
e.target.value = "";
|
|
145
168
|
};
|
|
146
169
|
|
|
147
170
|
const handleConfirmDelete = () => {
|
|
@@ -177,7 +200,7 @@ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
|
|
|
177
200
|
<input
|
|
178
201
|
type="file"
|
|
179
202
|
ref={fileInputRef}
|
|
180
|
-
accept={
|
|
203
|
+
accept={acceptAttr}
|
|
181
204
|
className="hidden"
|
|
182
205
|
onChange={handleFileChange}
|
|
183
206
|
/>
|
|
@@ -210,6 +233,10 @@ export const ProfileImageUpload: React.FC<ProfileImageUploadProps> = ({
|
|
|
210
233
|
</div>
|
|
211
234
|
|
|
212
235
|
<UploadProgressBar progress={uploadProgress} isLoading={isLoading} />
|
|
236
|
+
|
|
237
|
+
{aspectRatioHint && (
|
|
238
|
+
<p className="mt-1 text-xs text-gray-400">Ratio: {aspectRatioHint}</p>
|
|
239
|
+
)}
|
|
213
240
|
</div>
|
|
214
241
|
|
|
215
242
|
{/* Delete confirmation */}
|
|
@@ -100,6 +100,13 @@ export interface SelectProps<T = unknown> {
|
|
|
100
100
|
* Custom render function for selected value
|
|
101
101
|
*/
|
|
102
102
|
renderValue?: (option: SelectOption<T> | null) => React.ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* Custom render function for the trigger button content.
|
|
105
|
+
* When provided, replaces the selected-value/placeholder area and hides
|
|
106
|
+
* the built-in chevron so the trigger can render its own UI (e.g. a user
|
|
107
|
+
* avatar menu).
|
|
108
|
+
*/
|
|
109
|
+
renderTrigger?: () => React.ReactNode;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
@@ -125,6 +132,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
125
132
|
clearable = false,
|
|
126
133
|
renderOption,
|
|
127
134
|
renderValue,
|
|
135
|
+
renderTrigger,
|
|
128
136
|
...props
|
|
129
137
|
},
|
|
130
138
|
ref
|
|
@@ -177,17 +185,6 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
177
185
|
// Add some buffer (20px) to prevent edge cases
|
|
178
186
|
const buffer = 20;
|
|
179
187
|
|
|
180
|
-
console.log("Position calculation:", {
|
|
181
|
-
spaceBelow,
|
|
182
|
-
spaceAbove,
|
|
183
|
-
dropdownHeight,
|
|
184
|
-
viewportHeight,
|
|
185
|
-
containerBottom: containerRect.bottom,
|
|
186
|
-
shouldOpenTop:
|
|
187
|
-
spaceBelow < dropdownHeight + buffer &&
|
|
188
|
-
spaceAbove > dropdownHeight + buffer,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
188
|
// If there's not enough space below but enough space above, position on top
|
|
192
189
|
if (
|
|
193
190
|
spaceBelow < dropdownHeight + buffer &&
|
|
@@ -211,14 +208,6 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
211
208
|
const spaceAbove = containerRect.top;
|
|
212
209
|
const actualDropdownHeight = dropdownRect.height;
|
|
213
210
|
|
|
214
|
-
console.log("Position calculation with element:", {
|
|
215
|
-
spaceBelow,
|
|
216
|
-
spaceAbove,
|
|
217
|
-
actualDropdownHeight,
|
|
218
|
-
viewportHeight,
|
|
219
|
-
containerBottom: containerRect.bottom,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
211
|
// If there's not enough space below but enough space above, position on top
|
|
223
212
|
if (
|
|
224
213
|
spaceBelow < actualDropdownHeight &&
|
|
@@ -436,39 +425,48 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
|
436
425
|
{...props}
|
|
437
426
|
>
|
|
438
427
|
<div className="flex justify-between items-center">
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
428
|
+
{renderTrigger ? (
|
|
429
|
+
renderTrigger()
|
|
430
|
+
) : (
|
|
431
|
+
<>
|
|
432
|
+
<div className="flex-1 min-w-0">
|
|
433
|
+
{selectedOption ? (
|
|
434
|
+
renderValue ? (
|
|
435
|
+
renderValue(selectedOption)
|
|
436
|
+
) : (
|
|
437
|
+
<span className="truncate">{selectedOption.label}</span>
|
|
438
|
+
)
|
|
439
|
+
) : (
|
|
440
|
+
<span className="text-ews-gray-500">{placeholder}</span>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
450
443
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
444
|
+
<div className="flex items-center ml-2 space-x-1">
|
|
445
|
+
{clearable && selectedOption && !disabled && (
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={handleClear}
|
|
449
|
+
className="p-1 rounded hover:bg-ews-gray-100"
|
|
450
|
+
>
|
|
451
|
+
<X
|
|
452
|
+
className={cn(
|
|
453
|
+
iconSizeClasses[size],
|
|
454
|
+
"text-ews-gray-400"
|
|
455
|
+
)}
|
|
456
|
+
/>
|
|
457
|
+
</button>
|
|
458
|
+
)}
|
|
459
|
+
<ChevronDown
|
|
460
|
+
className={cn(
|
|
461
|
+
iconSizeClasses[size],
|
|
462
|
+
hasError ? "text-ews-error" : "text-ews-gray-400",
|
|
463
|
+
disabled && "text-ews-gray-300",
|
|
464
|
+
isOpen && "rotate-180 transition-transform"
|
|
465
|
+
)}
|
|
460
466
|
/>
|
|
461
|
-
</
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
className={cn(
|
|
465
|
-
iconSizeClasses[size],
|
|
466
|
-
hasError ? "text-ews-error" : "text-ews-gray-400",
|
|
467
|
-
disabled && "text-ews-gray-300",
|
|
468
|
-
isOpen && "rotate-180 transition-transform"
|
|
469
|
-
)}
|
|
470
|
-
/>
|
|
471
|
-
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</>
|
|
469
|
+
)}
|
|
472
470
|
</div>
|
|
473
471
|
</div>
|
|
474
472
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../../utils";
|
|
3
|
+
|
|
4
|
+
export interface TagProps {
|
|
5
|
+
label: string;
|
|
6
|
+
variant?: "primary" | "secondary" | "plain";
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Tag: React.FC<TagProps> = ({
|
|
11
|
+
label,
|
|
12
|
+
variant = "plain",
|
|
13
|
+
className,
|
|
14
|
+
}) => {
|
|
15
|
+
const variantClass =
|
|
16
|
+
variant === "primary"
|
|
17
|
+
? "bg-ews-primary text-white"
|
|
18
|
+
: variant === "secondary"
|
|
19
|
+
? "bg-ews-secondary text-white"
|
|
20
|
+
: "bg-gray-200 text-gray-800";
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<span
|
|
24
|
+
className={cn(
|
|
25
|
+
"inline-block rounded-md px-2.5 py-1 text-sm font-medium",
|
|
26
|
+
variantClass,
|
|
27
|
+
className
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
{label}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { cn } from "../../utils";
|
|
3
|
+
import { Tag, TagProps } from "../Tag/Tag";
|
|
4
|
+
|
|
5
|
+
export interface TagListProps {
|
|
6
|
+
items: string[];
|
|
7
|
+
maxVisible?: number;
|
|
8
|
+
showMoreLabel: string;
|
|
9
|
+
showLessLabel: string;
|
|
10
|
+
variant?: TagProps["variant"];
|
|
11
|
+
className?: string;
|
|
12
|
+
/** Custom renderer for each tag. Overrides the default Tag component. */
|
|
13
|
+
renderTag?: (label: string, index: number) => React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MAX = 3;
|
|
17
|
+
|
|
18
|
+
export const TagList: React.FC<TagListProps> = ({
|
|
19
|
+
items,
|
|
20
|
+
maxVisible = DEFAULT_MAX,
|
|
21
|
+
showMoreLabel,
|
|
22
|
+
showLessLabel,
|
|
23
|
+
variant = "secondary",
|
|
24
|
+
className,
|
|
25
|
+
renderTag,
|
|
26
|
+
}) => {
|
|
27
|
+
const [expanded, setExpanded] = useState(false);
|
|
28
|
+
|
|
29
|
+
const hasMore = items.length > maxVisible;
|
|
30
|
+
const visible = expanded ? items : items.slice(0, maxVisible);
|
|
31
|
+
|
|
32
|
+
const handleToggle = (e: React.MouseEvent) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
setExpanded(prev => !prev);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
|
40
|
+
{visible.map((label, i) =>
|
|
41
|
+
renderTag ? (
|
|
42
|
+
renderTag(label, i)
|
|
43
|
+
) : (
|
|
44
|
+
<Tag key={i} label={label} variant={variant} />
|
|
45
|
+
)
|
|
46
|
+
)}
|
|
47
|
+
{hasMore && !expanded && (
|
|
48
|
+
<span className="text-sm text-gray-400">...</span>
|
|
49
|
+
)}
|
|
50
|
+
{hasMore && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={handleToggle}
|
|
53
|
+
className="text-xs text-ews-primary hover:underline"
|
|
54
|
+
>
|
|
55
|
+
{expanded ? showLessLabel : showMoreLabel}
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,13 @@ export { Modal } from "./components/Modal";
|
|
|
17
17
|
export type { ErrorField, ErrorObject, ModalProps } from "./components/Modal";
|
|
18
18
|
|
|
19
19
|
export { ProfileImageUpload } from "./components/ProfileImageUpload";
|
|
20
|
-
export type {
|
|
20
|
+
export type {
|
|
21
|
+
AcceptedImageMimeType,
|
|
22
|
+
ProfileImageUploadProps,
|
|
23
|
+
} from "./components/ProfileImageUpload";
|
|
24
|
+
|
|
25
|
+
export { ImageCropModal } from "./components/ImageCropModal";
|
|
26
|
+
export type { ImageCropModalProps } from "./components/ImageCropModal";
|
|
21
27
|
|
|
22
28
|
export { DropdownMultiSelect } from "./components/DropdownMultiSelect";
|
|
23
29
|
export type { DropdownMultiSelectProps } from "./components/DropdownMultiSelect";
|
|
@@ -37,6 +43,12 @@ export type { ThemeDebuggerProps } from "./components/ThemeDebugger";
|
|
|
37
43
|
export { RegionSelect } from "./components/RegionSelect";
|
|
38
44
|
export type { RegionSelectProps } from "./components/RegionSelect";
|
|
39
45
|
|
|
46
|
+
export { Tag } from "./components/Tag";
|
|
47
|
+
export type { TagProps } from "./components/Tag";
|
|
48
|
+
|
|
49
|
+
export { TagList } from "./components/TagList";
|
|
50
|
+
export type { TagListProps } from "./components/TagList";
|
|
51
|
+
|
|
40
52
|
// Molecules
|
|
41
53
|
export { SpecialtySearchAutocomplete } from "./molecules";
|
|
42
54
|
export type { Specialty, SpecialtySearchAutocompleteProps } from "./molecules";
|