From 6a3704e1628a0ee30e9cba665be99d79f3cd656a Mon Sep 17 00:00:00 2001 From: michael ibinola Date: Fri, 24 Apr 2026 00:58:24 +0100 Subject: [PATCH] Add reusable frontend input modal toast and file upload components --- .../components/FileUpload/FileUpload.tsx | 106 ++++++++++++++++++ frontend/cmmty/components/FileUpload/index.ts | 3 + frontend/cmmty/components/FileUpload/types.ts | 11 ++ .../components/FileUpload/validation.test.ts | 26 +++++ .../cmmty/components/FileUpload/validation.ts | 23 ++++ .../cmmty/components/Input/Input.test.tsx | 29 +++++ frontend/cmmty/components/Input/Input.tsx | 82 ++++++++++++++ frontend/cmmty/components/Input/index.ts | 2 + frontend/cmmty/components/Input/types.ts | 15 +++ .../cmmty/components/Modal/Modal.test.tsx | 28 +++++ frontend/cmmty/components/Modal/Modal.tsx | 76 +++++++++++++ frontend/cmmty/components/Modal/index.ts | 2 + frontend/cmmty/components/Modal/types.ts | 12 ++ .../cmmty/components/Toast/Toast.test.tsx | 54 +++++++++ frontend/cmmty/components/Toast/Toast.tsx | 62 ++++++++++ frontend/cmmty/components/Toast/index.ts | 3 + frontend/cmmty/components/Toast/types.ts | 14 +++ frontend/cmmty/components/Toast/useToast.tsx | 63 +++++++++++ 18 files changed, 611 insertions(+) create mode 100644 frontend/cmmty/components/FileUpload/FileUpload.tsx create mode 100644 frontend/cmmty/components/FileUpload/index.ts create mode 100644 frontend/cmmty/components/FileUpload/types.ts create mode 100644 frontend/cmmty/components/FileUpload/validation.test.ts create mode 100644 frontend/cmmty/components/FileUpload/validation.ts create mode 100644 frontend/cmmty/components/Input/Input.test.tsx create mode 100644 frontend/cmmty/components/Input/Input.tsx create mode 100644 frontend/cmmty/components/Input/index.ts create mode 100644 frontend/cmmty/components/Input/types.ts create mode 100644 frontend/cmmty/components/Modal/Modal.test.tsx create mode 100644 frontend/cmmty/components/Modal/Modal.tsx create mode 100644 frontend/cmmty/components/Modal/index.ts create mode 100644 frontend/cmmty/components/Modal/types.ts create mode 100644 frontend/cmmty/components/Toast/Toast.test.tsx create mode 100644 frontend/cmmty/components/Toast/Toast.tsx create mode 100644 frontend/cmmty/components/Toast/index.ts create mode 100644 frontend/cmmty/components/Toast/types.ts create mode 100644 frontend/cmmty/components/Toast/useToast.tsx diff --git a/frontend/cmmty/components/FileUpload/FileUpload.tsx b/frontend/cmmty/components/FileUpload/FileUpload.tsx new file mode 100644 index 0000000..f6c78f7 --- /dev/null +++ b/frontend/cmmty/components/FileUpload/FileUpload.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { ChangeEvent, DragEvent, useRef, useState } from "react"; +import { UploadCloud } from "lucide-react"; +import { FileUploadProps } from "./types"; +import { validateFile } from "./validation"; + +function formatFileSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function FileUpload({ + allowedMimeTypes = [], + maxFileSizeBytes, + onFileSelect, + label = "Drag and drop a file here, or click to browse", +}: FileUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const inputRef = useRef(null); + + const processFile = (file: File | null) => { + if (!file) return; + + const result = validateFile(file, allowedMimeTypes, maxFileSizeBytes); + if (!result.isValid) { + setSelectedFile(null); + setErrorMessage(result.errorMessage ?? "Invalid file"); + return; + } + + setErrorMessage(null); + setSelectedFile(file); + onFileSelect?.(file); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setIsDragging(false); + processFile(event.dataTransfer.files.item(0)); + }; + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }; + + const onDragLeave = () => setIsDragging(false); + + const onChange = (event: ChangeEvent) => { + processFile(event.target.files?.item(0) ?? null); + }; + + return ( +
+
inputRef.current?.click()} + onDrop={onDrop} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + inputRef.current?.click(); + } + }} + className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 text-center transition ${ + isDragging + ? "border-blue-500 bg-blue-50" + : "border-gray-300 bg-gray-50 hover:border-blue-400" + }`} + > + +

{label}

+ {allowedMimeTypes.length > 0 ? ( +

+ Allowed types: {allowedMimeTypes.join(", ")} +

+ ) : null} +
+ + 0 ? allowedMimeTypes.join(",") : undefined} + /> + + {selectedFile ? ( +
+

Name: {selectedFile.name}

+

Size: {formatFileSize(selectedFile.size)}

+

Type: {selectedFile.type || "Unknown"}

+
+ ) : null} + + {errorMessage ?

{errorMessage}

: null} +
+ ); +} diff --git a/frontend/cmmty/components/FileUpload/index.ts b/frontend/cmmty/components/FileUpload/index.ts new file mode 100644 index 0000000..5dd70d3 --- /dev/null +++ b/frontend/cmmty/components/FileUpload/index.ts @@ -0,0 +1,3 @@ +export { default as FileUpload } from "./FileUpload"; +export { validateFile } from "./validation"; +export type { FileUploadProps, FileValidationResult } from "./types"; diff --git a/frontend/cmmty/components/FileUpload/types.ts b/frontend/cmmty/components/FileUpload/types.ts new file mode 100644 index 0000000..2a99047 --- /dev/null +++ b/frontend/cmmty/components/FileUpload/types.ts @@ -0,0 +1,11 @@ +export interface FileUploadProps { + allowedMimeTypes?: string[]; + maxFileSizeBytes?: number; + onFileSelect?: (file: File) => void; + label?: string; +} + +export interface FileValidationResult { + isValid: boolean; + errorMessage?: string; +} diff --git a/frontend/cmmty/components/FileUpload/validation.test.ts b/frontend/cmmty/components/FileUpload/validation.test.ts new file mode 100644 index 0000000..3b3ac23 --- /dev/null +++ b/frontend/cmmty/components/FileUpload/validation.test.ts @@ -0,0 +1,26 @@ +import { validateFile } from "./validation"; + +describe("validateFile", () => { + it("rejects unsupported MIME types", () => { + const file = new File(["content"], "notes.txt", { type: "text/plain" }); + const result = validateFile(file, ["application/pdf"], 1024); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toMatch(/unsupported file type/i); + }); + + it("rejects files larger than max", () => { + const file = new File([new Uint8Array(2048)], "large.pdf", { type: "application/pdf" }); + const result = validateFile(file, ["application/pdf"], 1024); + + expect(result.isValid).toBe(false); + expect(result.errorMessage).toMatch(/exceeds max size/i); + }); + + it("accepts valid files", () => { + const file = new File(["content"], "doc.pdf", { type: "application/pdf" }); + const result = validateFile(file, ["application/pdf"], 1024 * 1024); + + expect(result.isValid).toBe(true); + }); +}); diff --git a/frontend/cmmty/components/FileUpload/validation.ts b/frontend/cmmty/components/FileUpload/validation.ts new file mode 100644 index 0000000..25ab9eb --- /dev/null +++ b/frontend/cmmty/components/FileUpload/validation.ts @@ -0,0 +1,23 @@ +import { FileValidationResult } from "./types"; + +export function validateFile( + file: File, + allowedMimeTypes: string[] = [], + maxFileSizeBytes?: number, +): FileValidationResult { + if (allowedMimeTypes.length > 0 && !allowedMimeTypes.includes(file.type)) { + return { + isValid: false, + errorMessage: "Unsupported file type.", + }; + } + + if (maxFileSizeBytes && file.size > maxFileSizeBytes) { + return { + isValid: false, + errorMessage: `File exceeds max size of ${Math.round(maxFileSizeBytes / (1024 * 1024))}MB.`, + }; + } + + return { isValid: true }; +} diff --git a/frontend/cmmty/components/Input/Input.test.tsx b/frontend/cmmty/components/Input/Input.test.tsx new file mode 100644 index 0000000..4ee6c4f --- /dev/null +++ b/frontend/cmmty/components/Input/Input.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import Input from "./Input"; + +describe("Input", () => { + it("shows error state and message", () => { + render( + , + ); + + expect(screen.getByText("Email is required")).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true"); + }); + + it("toggles password visibility", () => { + render(); + + const input = screen.getByLabelText("Password"); + expect(input).toHaveAttribute("type", "password"); + + fireEvent.click(screen.getByRole("button", { name: /show password/i })); + expect(input).toHaveAttribute("type", "text"); + }); +}); diff --git a/frontend/cmmty/components/Input/Input.tsx b/frontend/cmmty/components/Input/Input.tsx new file mode 100644 index 0000000..acf4b17 --- /dev/null +++ b/frontend/cmmty/components/Input/Input.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useId, useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; +import { InputProps } from "./types"; + +export default function Input({ + id, + type = "text", + label, + helperText, + error, + errorMessage, + leftIcon, + rightIcon, + className, + disabled, + ...props +}: InputProps) { + const generatedId = useId(); + const inputId = id || generatedId; + const helperId = `${inputId}-helper`; + const errorId = `${inputId}-error`; + const [showPassword, setShowPassword] = useState(false); + + const resolvedType = + type === "password" && showPassword ? "text" : type; + + return ( +
+ {label ? ( + + ) : null} +
+ {leftIcon ? ( + + {leftIcon} + + ) : null} + + + {type === "password" ? ( + + ) : rightIcon ? ( + + {rightIcon} + + ) : null} +
+ {error && errorMessage ? ( +

+ {errorMessage} +

+ ) : helperText ? ( +

+ {helperText} +

+ ) : null} +
+ ); +} diff --git a/frontend/cmmty/components/Input/index.ts b/frontend/cmmty/components/Input/index.ts new file mode 100644 index 0000000..66b05eb --- /dev/null +++ b/frontend/cmmty/components/Input/index.ts @@ -0,0 +1,2 @@ +export { default as Input } from "./Input"; +export type { InputProps, InputType } from "./types"; diff --git a/frontend/cmmty/components/Input/types.ts b/frontend/cmmty/components/Input/types.ts new file mode 100644 index 0000000..d925063 --- /dev/null +++ b/frontend/cmmty/components/Input/types.ts @@ -0,0 +1,15 @@ +import { InputHTMLAttributes, ReactNode } from "react"; + +export type InputType = "text" | "email" | "password"; + +export interface InputProps + extends Omit, "type"> { + id?: string; + type?: InputType; + label?: string; + helperText?: string; + error?: boolean; + errorMessage?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} diff --git a/frontend/cmmty/components/Modal/Modal.test.tsx b/frontend/cmmty/components/Modal/Modal.test.tsx new file mode 100644 index 0000000..1b38d2d --- /dev/null +++ b/frontend/cmmty/components/Modal/Modal.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import Modal from "./Modal"; + +describe("Modal", () => { + it("renders provided content", () => { + render( + +

Body content

+
, + ); + + expect(screen.getByText("Test Modal")).toBeInTheDocument(); + expect(screen.getByText("Body content")).toBeInTheDocument(); + }); + + it("calls onClose on ESC press", () => { + const onClose = jest.fn(); + + render( + +

Body content

+
, + ); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/cmmty/components/Modal/Modal.tsx b/frontend/cmmty/components/Modal/Modal.tsx new file mode 100644 index 0000000..ddfc780 --- /dev/null +++ b/frontend/cmmty/components/Modal/Modal.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect } from "react"; +import { X } from "lucide-react"; +import { ModalProps } from "./types"; + +const sizeClasses = { + sm: "max-w-sm", + md: "max-w-lg", + lg: "max-w-2xl", +}; + +export default function Modal({ + open, + title, + description, + children, + size = "md", + onClose, +}: ModalProps) { + useEffect(() => { + if (!open) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose?.(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onClose, open]); + + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) { + onClose?.(); + } + }} + > +
+
+
+ + {description ? ( + + ) : null} +
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/cmmty/components/Modal/index.ts b/frontend/cmmty/components/Modal/index.ts new file mode 100644 index 0000000..c30399b --- /dev/null +++ b/frontend/cmmty/components/Modal/index.ts @@ -0,0 +1,2 @@ +export { default as Modal } from "./Modal"; +export type { ModalProps, ModalSize } from "./types"; diff --git a/frontend/cmmty/components/Modal/types.ts b/frontend/cmmty/components/Modal/types.ts new file mode 100644 index 0000000..9449bb6 --- /dev/null +++ b/frontend/cmmty/components/Modal/types.ts @@ -0,0 +1,12 @@ +import { ReactNode } from "react"; + +export type ModalSize = "sm" | "md" | "lg"; + +export interface ModalProps { + open: boolean; + title: string; + description?: string; + children: ReactNode; + size?: ModalSize; + onClose?: () => void; +} diff --git a/frontend/cmmty/components/Toast/Toast.test.tsx b/frontend/cmmty/components/Toast/Toast.test.tsx new file mode 100644 index 0000000..dd45ee2 --- /dev/null +++ b/frontend/cmmty/components/Toast/Toast.test.tsx @@ -0,0 +1,54 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { ToastProvider, useToast } from "./useToast"; +import ToastStack from "./Toast"; + +function Trigger() { + const { showToast } = useToast(); + return ( + + ); +} + +describe("Toast", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it("displays toast and dismisses automatically", () => { + render( + + + + , + ); + + fireEvent.click(screen.getByText("Trigger")); + expect(screen.getByText("Saved")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("dismisses manually", () => { + render( + + + + , + ); + + fireEvent.click(screen.getByText("Trigger")); + fireEvent.click(screen.getByRole("button", { name: /dismiss notification/i })); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/cmmty/components/Toast/Toast.tsx b/frontend/cmmty/components/Toast/Toast.tsx new file mode 100644 index 0000000..06740f8 --- /dev/null +++ b/frontend/cmmty/components/Toast/Toast.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from "lucide-react"; +import { ComponentType } from "react"; +import { useToast } from "./useToast"; +import { ToastType } from "./types"; + +const toastTheme: Record; style: string }> = { + success: { + icon: CheckCircle2, + style: "border-emerald-200 bg-emerald-50 text-emerald-900", + }, + error: { + icon: AlertCircle, + style: "border-red-200 bg-red-50 text-red-900", + }, + warning: { + icon: TriangleAlert, + style: "border-amber-200 bg-amber-50 text-amber-900", + }, + info: { + icon: Info, + style: "border-blue-200 bg-blue-50 text-blue-900", + }, +}; + +export default function ToastStack() { + const { toasts, dismissToast } = useToast(); + + return ( +
+ {toasts.map((toast) => { + const theme = toastTheme[toast.type]; + const Icon = theme.icon; + + return ( +
+ +
+

{toast.title}

+ {toast.description ? ( +

{toast.description}

+ ) : null} +
+ +
+ ); + })} +
+ ); +} diff --git a/frontend/cmmty/components/Toast/index.ts b/frontend/cmmty/components/Toast/index.ts new file mode 100644 index 0000000..c0b10ca --- /dev/null +++ b/frontend/cmmty/components/Toast/index.ts @@ -0,0 +1,3 @@ +export { default as ToastStack } from "./Toast"; +export { ToastProvider, useToast } from "./useToast"; +export type { ToastType, ToastItem, ToastOptions } from "./types"; diff --git a/frontend/cmmty/components/Toast/types.ts b/frontend/cmmty/components/Toast/types.ts new file mode 100644 index 0000000..3c6c610 --- /dev/null +++ b/frontend/cmmty/components/Toast/types.ts @@ -0,0 +1,14 @@ +export type ToastType = "success" | "error" | "warning" | "info"; + +export interface ToastOptions { + title: string; + description?: string; + type?: ToastType; + duration?: number; +} + +export interface ToastItem extends ToastOptions { + id: string; + type: ToastType; + duration: number; +} diff --git a/frontend/cmmty/components/Toast/useToast.tsx b/frontend/cmmty/components/Toast/useToast.tsx new file mode 100644 index 0000000..f34a09e --- /dev/null +++ b/frontend/cmmty/components/Toast/useToast.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react"; +import { ToastItem, ToastOptions } from "./types"; + +interface ToastContextValue { + toasts: ToastItem[]; + showToast: (options: ToastOptions) => void; + dismissToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const dismissToast = useCallback((id: string) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback((options: ToastOptions) => { + const id = crypto.randomUUID(); + const toast: ToastItem = { + id, + title: options.title, + description: options.description, + type: options.type ?? "info", + duration: options.duration ?? 4000, + }; + + setToasts((current) => [...current, toast]); + + window.setTimeout(() => { + setToasts((current) => current.filter((item) => item.id !== id)); + }, toast.duration); + }, []); + + const value = useMemo( + () => ({ + toasts, + showToast, + dismissToast, + }), + [dismissToast, showToast, toasts], + ); + + return {children}; +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +}