@mbao01/common 0.0.32 → 0.0.34

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.
@@ -0,0 +1,11 @@
1
+ /// <reference types="react" />
2
+ import type { FileUploaderProps } from "./types";
3
+ export declare const FileUploader: {
4
+ ({ className, dropzoneOptions, value, onValueChange, reSelect, orientation, children, dir, ...props }: FileUploaderProps): import("react/jsx-runtime").JSX.Element;
5
+ displayName: string;
6
+ Content: import("react").ForwardRefExoticComponent<import("react").HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
7
+ Input: import("react").ForwardRefExoticComponent<import("react").HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
8
+ Item: import("react").ForwardRefExoticComponent<{
9
+ index: number;
10
+ } & import("react").HTMLAttributes<HTMLDivElement> & import("react").RefAttributes<HTMLDivElement>>;
11
+ };
@@ -0,0 +1,3 @@
1
+ /// <reference types="react" />
2
+ import { type FileUploaderContextType } from "./types";
3
+ export declare const FileUploaderContext: import("react").Context<FileUploaderContextType | null>;
@@ -0,0 +1 @@
1
+ export { FileUploader } from "./FileUploader";
@@ -0,0 +1,20 @@
1
+ import type { Dispatch, SetStateAction } from "react";
2
+ import type { DropzoneState, DropzoneOptions } from "react-dropzone";
3
+ export type FileUploaderProps = {
4
+ value: File[] | null;
5
+ reSelect?: boolean;
6
+ onValueChange: (value: File[] | null) => void;
7
+ dropzoneOptions: DropzoneOptions;
8
+ orientation?: "horizontal" | "vertical";
9
+ } & React.HTMLAttributes<HTMLDivElement>;
10
+ export type DirectionOptions = "rtl" | "ltr" | undefined;
11
+ export type FileUploaderContextType = {
12
+ dropzoneState: DropzoneState;
13
+ isLOF: boolean;
14
+ isFileTooBig: boolean;
15
+ removeFileFromSet: (index: number) => void;
16
+ activeIndex: number;
17
+ setActiveIndex: Dispatch<SetStateAction<number>>;
18
+ orientation: "horizontal" | "vertical";
19
+ direction: DirectionOptions;
20
+ };
@@ -0,0 +1 @@
1
+ export declare const useFileUpload: () => import("./types").FileUploaderContextType;
@@ -0,0 +1,2 @@
1
+ import { type OtpInputProps } from "./types";
2
+ export declare const OtpInput: ({ className, inputProps, ...props }: OtpInputProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1 @@
1
+ export { OtpInput } from "./OtpInput";
@@ -0,0 +1,6 @@
1
+ import { type OTPInputProps } from "react-otp-input";
2
+ import { InputProps } from "../Input/types";
3
+ export type OtpInputProps = {
4
+ className?: string;
5
+ inputProps?: Omit<InputProps, "type">;
6
+ } & Omit<OTPInputProps, "renderInput" | "onChange">;
@@ -1,4 +1,5 @@
1
1
  export { Input } from "./Input";
2
+ export { OtpInput } from "./OtpInput";
2
3
  export { Phone } from "./Phone";
3
4
  export { Radio } from "./Radio";
4
5
  export { Range } from "./Range";
@@ -24,6 +24,7 @@ export * from "./components/Table";
24
24
  export * from "./components/Tabs";
25
25
  export * from "./components/Text";
26
26
  /** data input */
27
+ export * from "./components/FileUploader";
27
28
  export * from "./components/Form";
28
29
  export * from "./components/Combobox";
29
30
  export * from "./components/DatePicker";
@@ -1,3 +1,4 @@
1
1
  export type Theme = "dark" | "light";
2
+ export declare const THEME_COOKIE_NAME = "data-theme";
2
3
  export declare const getTheme: () => Theme | null;
3
4
  export declare const saveTheme: (theme: Theme) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mbao01/common",
3
3
  "private": false,
4
- "version": "0.0.32",
4
+ "version": "0.0.34",
5
5
  "type": "module",
6
6
  "author": "Ayomide Bakare",
7
7
  "license": "MIT",
@@ -91,10 +91,13 @@
91
91
  "date-fns": "^3.6.0",
92
92
  "embla-carousel-react": "^8.0.2",
93
93
  "react-day-picker": "^8.10.0",
94
+ "react-dropzone": "^14.2.3",
94
95
  "react-international-phone": "^4.2.6",
96
+ "react-otp-input": "^3.1.1",
95
97
  "sonner": "^1.4.41",
96
98
  "tailwind-merge": "^2.2.1",
97
99
  "tailwindcss-animate": "^1.0.7",
100
+ "universal-cookie": "^7.1.4",
98
101
  "vaul": "^0.9.0"
99
102
  },
100
103
  "devDependencies": {
@@ -142,5 +145,5 @@
142
145
  "react-dom": "^18.2.0",
143
146
  "typescript": "^5.2.2"
144
147
  },
145
- "gitHead": "3646dbdb6bcfc492b8ec63094f6d16f98c73d4dc"
148
+ "gitHead": "cc3e285416d8c9184e0bf597400550d08847001e"
146
149
  }
@@ -0,0 +1,309 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
4
+ import { useDropzone, FileRejection } from "react-dropzone";
5
+ import { toast } from "sonner";
6
+ import { TrashIcon } from "@radix-ui/react-icons";
7
+ import type { DirectionOptions, FileUploaderProps } from "./types";
8
+ import { cn } from "../../utilities";
9
+ import { FileUploaderContext } from "./FileUploaderContext";
10
+ import { useFileUpload } from "./useFileUpload";
11
+
12
+ export const FileUploader = ({
13
+ className,
14
+ dropzoneOptions,
15
+ value,
16
+ onValueChange,
17
+ reSelect,
18
+ orientation = "vertical",
19
+ children,
20
+ dir,
21
+ ...props
22
+ }: FileUploaderProps) => {
23
+ const [isFileTooBig, setIsFileTooBig] = useState(false);
24
+ const [isLOF, setIsLOF] = useState(false);
25
+ const [activeIndex, setActiveIndex] = useState(-1);
26
+ const {
27
+ accept = {
28
+ "image/*": [".jpg", ".jpeg", ".png", ".gif"],
29
+ },
30
+ maxFiles = 1,
31
+ maxSize = 4 * 1024 * 1024,
32
+ multiple = true,
33
+ } = dropzoneOptions;
34
+
35
+ const reSelectAll = maxFiles === 1 ? true : reSelect;
36
+ const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr";
37
+
38
+ const removeFileFromSet = useCallback(
39
+ (i: number) => {
40
+ if (!value) return;
41
+ const newFiles = value.filter((_, index) => index !== i);
42
+ onValueChange(newFiles);
43
+ },
44
+ [value, onValueChange]
45
+ );
46
+
47
+ const handleKeyDown = useCallback(
48
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+
52
+ if (!value) return;
53
+
54
+ const moveNext = () => {
55
+ const nextIndex = activeIndex + 1;
56
+ setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);
57
+ };
58
+
59
+ const movePrev = () => {
60
+ const nextIndex = activeIndex - 1;
61
+ setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);
62
+ };
63
+
64
+ const prevKey =
65
+ orientation === "horizontal"
66
+ ? direction === "ltr"
67
+ ? "ArrowLeft"
68
+ : "ArrowRight"
69
+ : "ArrowUp";
70
+
71
+ const nextKey =
72
+ orientation === "horizontal"
73
+ ? direction === "ltr"
74
+ ? "ArrowRight"
75
+ : "ArrowLeft"
76
+ : "ArrowDown";
77
+
78
+ if (e.key === nextKey) {
79
+ moveNext();
80
+ } else if (e.key === prevKey) {
81
+ movePrev();
82
+ } else if (e.key === "Enter" || e.key === "Space") {
83
+ if (activeIndex === -1) {
84
+ dropzoneState.inputRef.current?.click();
85
+ }
86
+ } else if (e.key === "Delete" || e.key === "Backspace") {
87
+ if (activeIndex !== -1) {
88
+ removeFileFromSet(activeIndex);
89
+ if (value.length - 1 === 0) {
90
+ setActiveIndex(-1);
91
+ return;
92
+ }
93
+ movePrev();
94
+ }
95
+ } else if (e.key === "Escape") {
96
+ setActiveIndex(-1);
97
+ }
98
+ },
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ [value, activeIndex, removeFileFromSet]
101
+ );
102
+
103
+ const onDrop = useCallback(
104
+ (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
105
+ const files = acceptedFiles;
106
+
107
+ if (!files) {
108
+ toast.error("file error , probably too big");
109
+ return;
110
+ }
111
+
112
+ const newValues: File[] = value ? [...value] : [];
113
+
114
+ if (reSelectAll) {
115
+ newValues.splice(0, newValues.length);
116
+ }
117
+
118
+ files.forEach((file) => {
119
+ if (newValues.length < maxFiles) {
120
+ newValues.push(file);
121
+ }
122
+ });
123
+
124
+ onValueChange(newValues);
125
+
126
+ if (rejectedFiles.length > 0) {
127
+ for (const rejectedFile of rejectedFiles) {
128
+ if (rejectedFile.errors[0]?.code === "file-too-large") {
129
+ toast.error(
130
+ `File is too large. Max size is ${maxSize / 1024 / 1024}MB`
131
+ );
132
+ break;
133
+ }
134
+ if (rejectedFile.errors[0]?.message) {
135
+ toast.error(rejectedFile.errors[0].message);
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ },
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ [reSelectAll, value]
143
+ );
144
+
145
+ useEffect(() => {
146
+ if (!value) return;
147
+ if (value.length === maxFiles) {
148
+ setIsLOF(true);
149
+ return;
150
+ }
151
+ setIsLOF(false);
152
+ }, [value, maxFiles]);
153
+
154
+ const opts = dropzoneOptions
155
+ ? dropzoneOptions
156
+ : { accept, maxFiles, maxSize, multiple };
157
+
158
+ const dropzoneState = useDropzone({
159
+ ...opts,
160
+ onDrop,
161
+ onDropRejected: () => setIsFileTooBig(true),
162
+ onDropAccepted: () => setIsFileTooBig(false),
163
+ });
164
+
165
+ return (
166
+ <FileUploaderContext.Provider
167
+ value={{
168
+ dropzoneState,
169
+ isLOF,
170
+ isFileTooBig,
171
+ removeFileFromSet,
172
+ activeIndex,
173
+ setActiveIndex,
174
+ orientation,
175
+ direction,
176
+ }}
177
+ >
178
+ <div
179
+ tabIndex={0}
180
+ onKeyDownCapture={handleKeyDown}
181
+ className={cn(
182
+ "grid w-full focus:outline-none overflow-hidden ",
183
+ className,
184
+ {
185
+ "gap-2": value && value.length > 0,
186
+ }
187
+ )}
188
+ dir={dir}
189
+ {...props}
190
+ >
191
+ {children}
192
+ </div>
193
+ </FileUploaderContext.Provider>
194
+ );
195
+ };
196
+
197
+ FileUploader.displayName = "FileUploader";
198
+
199
+ const FileUploaderContent = forwardRef<
200
+ HTMLDivElement,
201
+ React.HTMLAttributes<HTMLDivElement>
202
+ >(({ children, className, ...props }, ref) => {
203
+ const { orientation } = useFileUpload();
204
+ const containerRef = useRef<HTMLDivElement>(null);
205
+
206
+ return (
207
+ <div
208
+ className={cn("w-full px-1")}
209
+ ref={containerRef}
210
+ aria-description="content file holder"
211
+ >
212
+ <div
213
+ {...props}
214
+ ref={ref}
215
+ className={cn(
216
+ "flex rounded-xl gap-1",
217
+ orientation === "horizontal" ? "flex-raw flex-wrap" : "flex-col",
218
+ className
219
+ )}
220
+ >
221
+ {children}
222
+ </div>
223
+ </div>
224
+ );
225
+ });
226
+
227
+ FileUploaderContent.displayName = "FileUploaderContent";
228
+
229
+ const FileUploaderItem = forwardRef<
230
+ HTMLDivElement,
231
+ { index: number } & React.HTMLAttributes<HTMLDivElement>
232
+ >(({ className, index, children, ...props }, ref) => {
233
+ const { removeFileFromSet, activeIndex, direction } = useFileUpload();
234
+ const isSelected = index === activeIndex;
235
+ return (
236
+ <div
237
+ ref={ref}
238
+ className={cn(
239
+ "h-6 p-1 justify-between cursor-pointer relative",
240
+ className,
241
+ isSelected ? "bg-muted" : ""
242
+ )}
243
+ {...props}
244
+ >
245
+ <div className="text-sm leading-none tracking-tight flex items-center gap-1.5 h-full w-full">
246
+ {children}
247
+ </div>
248
+ <button
249
+ type="button"
250
+ className={cn(
251
+ "absolute",
252
+ direction === "rtl" ? "top-1 left-1" : "top-1 right-1"
253
+ )}
254
+ onClick={() => removeFileFromSet(index)}
255
+ >
256
+ <span className="sr-only">remove item {index}</span>
257
+ <TrashIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
258
+ </button>
259
+ </div>
260
+ );
261
+ });
262
+
263
+ FileUploaderItem.displayName = "FileUploaderItem";
264
+
265
+ const FileUploaderInput = forwardRef<
266
+ HTMLDivElement,
267
+ React.HTMLAttributes<HTMLDivElement>
268
+ >(({ className, children, ...props }, ref) => {
269
+ const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
270
+ const rootProps = isLOF ? {} : dropzoneState.getRootProps();
271
+ return (
272
+ <div
273
+ ref={ref}
274
+ {...props}
275
+ className={`relative w-full ${
276
+ isLOF ? "opacity-50 cursor-not-allowed " : "cursor-pointer "
277
+ }`}
278
+ >
279
+ <div
280
+ className={cn(
281
+ `w-full rounded-lg duration-300 ease-in-out
282
+ ${
283
+ dropzoneState.isDragAccept
284
+ ? "border-green-500"
285
+ : dropzoneState.isDragReject || isFileTooBig
286
+ ? "border-red-500"
287
+ : "border-gray-300"
288
+ }`,
289
+ className
290
+ )}
291
+ {...rootProps}
292
+ >
293
+ {children}
294
+ </div>
295
+ <input
296
+ ref={dropzoneState.inputRef}
297
+ disabled={isLOF}
298
+ {...dropzoneState.getInputProps()}
299
+ className={`${isLOF ? "cursor-not-allowed" : ""}`}
300
+ />
301
+ </div>
302
+ );
303
+ });
304
+
305
+ FileUploaderInput.displayName = "FileInput";
306
+
307
+ FileUploader.Content = FileUploaderContent;
308
+ FileUploader.Input = FileUploaderInput;
309
+ FileUploader.Item = FileUploaderItem;
@@ -0,0 +1,5 @@
1
+ import { createContext } from "react";
2
+ import { type FileUploaderContextType } from "./types";
3
+
4
+ export const FileUploaderContext =
5
+ createContext<FileUploaderContextType | null>(null);
@@ -0,0 +1 @@
1
+ export { FileUploader } from "./FileUploader";
@@ -0,0 +1,23 @@
1
+ import type { Dispatch, SetStateAction } from "react";
2
+ import type { DropzoneState, DropzoneOptions } from "react-dropzone";
3
+
4
+ export type FileUploaderProps = {
5
+ value: File[] | null;
6
+ reSelect?: boolean;
7
+ onValueChange: (value: File[] | null) => void;
8
+ dropzoneOptions: DropzoneOptions;
9
+ orientation?: "horizontal" | "vertical";
10
+ } & React.HTMLAttributes<HTMLDivElement>;
11
+
12
+ export type DirectionOptions = "rtl" | "ltr" | undefined;
13
+
14
+ export type FileUploaderContextType = {
15
+ dropzoneState: DropzoneState;
16
+ isLOF: boolean;
17
+ isFileTooBig: boolean;
18
+ removeFileFromSet: (index: number) => void;
19
+ activeIndex: number;
20
+ setActiveIndex: Dispatch<SetStateAction<number>>;
21
+ orientation: "horizontal" | "vertical";
22
+ direction: DirectionOptions;
23
+ };
@@ -0,0 +1,10 @@
1
+ import { useContext } from "react";
2
+ import { FileUploaderContext } from "./FileUploaderContext";
3
+
4
+ export const useFileUpload = () => {
5
+ const context = useContext(FileUploaderContext);
6
+ if (!context) {
7
+ throw new Error("useFileUpload must be used within a FileUploaderProvider");
8
+ }
9
+ return context;
10
+ };
@@ -0,0 +1,34 @@
1
+ import OTPInput from "react-otp-input";
2
+ import { type OtpInputProps } from "./types";
3
+ import { Input } from "../Input";
4
+ import { cn } from "../../../utilities";
5
+ import { useState } from "react";
6
+
7
+ export const OtpInput = ({
8
+ className,
9
+ inputProps,
10
+ ...props
11
+ }: OtpInputProps) => {
12
+ const [otp, setOtp] = useState("");
13
+
14
+ return (
15
+ <OTPInput
16
+ {...props}
17
+ value={otp}
18
+ onChange={setOtp}
19
+ renderInput={(renderProps) => (
20
+ <Input
21
+ {...inputProps}
22
+ {...renderProps}
23
+ className={cn(
24
+ "!w-12 !appearance-none selection:bg-base text-base-content",
25
+ className
26
+ )}
27
+ />
28
+ )}
29
+ containerStyle={`flex justify-center items-center flex-wrap text-2xl font-bold ${
30
+ props.renderSeparator ? "gap-1" : "gap-x-3 gap-y-2"
31
+ }`}
32
+ />
33
+ );
34
+ };
@@ -0,0 +1 @@
1
+ export { OtpInput } from "./OtpInput";
@@ -0,0 +1,7 @@
1
+ import { type OTPInputProps } from "react-otp-input";
2
+ import { InputProps } from "../Input/types";
3
+
4
+ export type OtpInputProps = {
5
+ className?: string;
6
+ inputProps?: Omit<InputProps, "type">;
7
+ } & Omit<OTPInputProps, "renderInput" | "onChange">;
@@ -1,4 +1,5 @@
1
1
  export { Input } from "./Input";
2
+ export { OtpInput } from "./OtpInput";
2
3
  export { Phone } from "./Phone";
3
4
  export { Radio } from "./Radio";
4
5
  export { Range } from "./Range";
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export * from "./components/Tabs";
26
26
  export * from "./components/Text";
27
27
 
28
28
  /** data input */
29
+ export * from "./components/FileUploader";
29
30
  export * from "./components/Form";
30
31
  export * from "./components/Combobox";
31
32
  export * from "./components/DatePicker";
@@ -1,17 +1,19 @@
1
+ import Cookies from "universal-cookie";
2
+
1
3
  export type Theme = "dark" | "light";
2
4
 
5
+ export const THEME_COOKIE_NAME = "data-theme";
6
+
3
7
  export const getTheme = () => {
4
8
  if (typeof window === "undefined") return null;
5
9
 
6
- let t = window.localStorage.getItem("__theme") as Theme;
7
- if (!t)
8
- t = window.matchMedia("(prefers-color-scheme: dark)")?.matches
9
- ? "dark"
10
- : "light";
11
- return t;
10
+ const cookies = new Cookies();
11
+ const theme = cookies.get(THEME_COOKIE_NAME) as Theme;
12
+ return theme;
12
13
  };
13
14
 
14
15
  export const saveTheme = (theme: Theme) => {
15
- document.body.setAttribute("data-theme", theme);
16
- window.localStorage.setItem("__theme", theme);
16
+ const cookies = new Cookies();
17
+ cookies.set(THEME_COOKIE_NAME, theme, { secure: true });
18
+ document.body.setAttribute(THEME_COOKIE_NAME, theme);
17
19
  };